Skip to main content

s2_common/record/
encryption.rs

1//! Encrypted record storage, wire format, and raw cryptography.
2//!
3//! ```text
4//! [format_id: 1 byte] [nonce] [ciphertext] [tag]
5//! ```
6//!
7//! | format_id | Format         | Nonce  | Tag  |
8//! |-----------|----------------|--------|------|
9//! | 0x01      | AEGIS-256 v1   | 32 B   | 16 B |
10//! | 0x02      | AES-256-GCM v1 | 12 B   | 16 B |
11//!
12//! The leading format byte identifies the full encrypted record framing,
13//! including the framing version and encryption algorithm. This leaves room for
14//! future layout changes without a separate version byte.
15//!
16//! AAD is caller-supplied associated data and is not stored in the encoded
17//! record.
18//!
19//! Plaintext records are stored as `StoredRecord::Plaintext(Record)` and use
20//! the same command/envelope framing as the logical record layer.
21//!
22//! Encrypted envelope records are stored as `StoredRecord::Encrypted`. Their
23//! outer record type is `RecordType::EncryptedEnvelope`, and the encoded body is
24//! an [`EncryptedRecord`] containing encrypted bytes for the byte-for-byte
25//! plaintext [`EnvelopeRecord`](super::EnvelopeRecord) encoding.
26//!
27//! The stored `metered_size` remains the logical plaintext metered size rather
28//! than the encoded encrypted record size, so protection does not change
29//! append/read metering, limits, or accounting.
30
31use aegis::aegis256::Aegis256;
32use aes_gcm::{Aes256Gcm, KeyInit, aead::AeadInPlace};
33use bytes::{BufMut, Bytes, BytesMut};
34use rand::random;
35
36use super::{Encodable, Metered, MeteredSize, Record, RecordDecodeError, StoredRecord};
37use crate::{
38    deep_size::DeepSize,
39    encryption::{EncryptionAlgorithm, EncryptionSpec},
40    record::MeteredExt as _,
41};
42
43const FORMAT_ID_LEN: usize = 1;
44
45const FORMAT_ID_AEGIS256_V1: u8 = 0x01;
46const FORMAT_ID_AES256GCM_V1: u8 = 0x02;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub(crate) enum EncryptedRecordFormat {
50    Aegis256V1,
51    Aes256GcmV1,
52}
53
54impl EncryptedRecordFormat {
55    const fn try_from_format_id(format_id: u8) -> Result<Self, RecordDecodeError> {
56        match format_id {
57            FORMAT_ID_AEGIS256_V1 => Ok(Self::Aegis256V1),
58            FORMAT_ID_AES256GCM_V1 => Ok(Self::Aes256GcmV1),
59            _ => Err(RecordDecodeError::InvalidValue(
60                "EncryptedRecord",
61                "invalid encrypted record format id",
62            )),
63        }
64    }
65
66    const fn format_id(self) -> u8 {
67        match self {
68            Self::Aegis256V1 => FORMAT_ID_AEGIS256_V1,
69            Self::Aes256GcmV1 => FORMAT_ID_AES256GCM_V1,
70        }
71    }
72
73    const fn algorithm(self) -> EncryptionAlgorithm {
74        match self {
75            Self::Aegis256V1 => EncryptionAlgorithm::Aegis256,
76            Self::Aes256GcmV1 => EncryptionAlgorithm::Aes256Gcm,
77        }
78    }
79
80    const fn nonce_len(self) -> usize {
81        match self {
82            Self::Aegis256V1 => 32,
83            Self::Aes256GcmV1 => 12,
84        }
85    }
86
87    const fn tag_len(self) -> usize {
88        match self {
89            Self::Aegis256V1 => 16,
90            Self::Aes256GcmV1 => 16,
91        }
92    }
93
94    fn put_random_nonce(self, buf: &mut impl BufMut) {
95        match self {
96            Self::Aegis256V1 => buf.put_slice(&random::<[u8; 32]>()),
97            Self::Aes256GcmV1 => buf.put_slice(&random::<[u8; 12]>()),
98        }
99    }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
103pub enum RecordDecryptionError {
104    #[error("record encryption algorithm mismatch")]
105    AlgorithmMismatch {
106        expected: Option<EncryptionAlgorithm>,
107        actual: Option<EncryptionAlgorithm>,
108    },
109    #[error("record decryption failed")]
110    AuthenticationFailed,
111    #[error("malformed encrypted record")]
112    MalformedEncryptedRecord,
113    #[error("decrypted record metered size mismatch: stored {stored}, actual {actual}")]
114    MeteredSizeMismatch { stored: usize, actual: usize },
115    #[error("malformed decrypted record: {0}")]
116    MalformedDecryptedRecord(#[from] RecordDecodeError),
117}
118
119#[derive(PartialEq, Eq, Clone)]
120pub struct EncryptedRecord {
121    encoded: Bytes,
122    format: EncryptedRecordFormat,
123}
124
125impl EncryptedRecord {
126    fn new(encoded: Bytes, format: EncryptedRecordFormat) -> Self {
127        debug_assert!(!encoded.is_empty());
128        debug_assert_eq!(encoded[0], format.format_id());
129        debug_assert!(encoded.len() >= FORMAT_ID_LEN + format.nonce_len() + format.tag_len());
130        Self { encoded, format }
131    }
132
133    pub fn algorithm(&self) -> EncryptionAlgorithm {
134        self.format.algorithm()
135    }
136
137    pub(crate) fn nonce(&self) -> &[u8] {
138        let start = FORMAT_ID_LEN;
139        let end = start + self.format.nonce_len();
140        &self.encoded[start..end]
141    }
142
143    pub(crate) fn ciphertext(&self) -> &[u8] {
144        let start = FORMAT_ID_LEN + self.format.nonce_len();
145        let end = self.encoded.len() - self.format.tag_len();
146        &self.encoded[start..end]
147    }
148
149    pub(crate) fn tag(&self) -> &[u8] {
150        let start = self.encoded.len() - self.format.tag_len();
151        let end = self.encoded.len();
152        &self.encoded[start..end]
153    }
154
155    fn into_mut_encoded(self) -> BytesMut {
156        self.encoded
157            .try_into_mut()
158            .unwrap_or_else(|encoded| BytesMut::from(encoded.as_ref()))
159    }
160}
161
162impl std::fmt::Debug for EncryptedRecord {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.debug_struct("EncryptedRecord")
165            .field("format_id", &self.encoded[0])
166            .field("format", &self.format)
167            .field("algorithm", &self.format.algorithm())
168            .field("nonce.len", &self.nonce().len())
169            .field("ciphertext.len", &self.ciphertext().len())
170            .field("tag.len", &self.tag().len())
171            .finish()
172    }
173}
174
175impl DeepSize for EncryptedRecord {
176    fn deep_size(&self) -> usize {
177        self.encoded.len()
178    }
179}
180
181impl Encodable for EncryptedRecord {
182    fn encoded_size(&self) -> usize {
183        self.encoded.len()
184    }
185
186    fn encode_into(&self, buf: &mut impl BufMut) {
187        buf.put_slice(self.encoded.as_ref());
188    }
189}
190
191pub fn encrypt_record(
192    record: Metered<Record>,
193    encryption: &EncryptionSpec,
194    aad: &[u8],
195) -> Metered<StoredRecord> {
196    let metered_size = record.metered_size();
197    let record = match (record.into_inner(), encryption) {
198        (record @ Record::Command(_), _) => StoredRecord::Plaintext(record),
199        (record @ Record::Envelope(_), EncryptionSpec::Plain) => StoredRecord::Plaintext(record),
200        (Record::Envelope(envelope), EncryptionSpec::Aegis256(key)) => {
201            let format = EncryptedRecordFormat::Aegis256V1;
202            let (mut encoded, payload_start) = prep_encryption_buffer(&envelope, format);
203            let (prefix, payload) = encoded.split_at_mut(payload_start);
204            let nonce: &[u8; 32] = prefix[FORMAT_ID_LEN..]
205                .try_into()
206                .expect("AEGIS-256 nonce must be 32 bytes");
207            let tag =
208                Aegis256::<16>::new(key.expose_secret(), nonce).encrypt_in_place(payload, aad);
209            encoded.put_slice(tag.as_ref());
210
211            let encrypted = EncryptedRecord::new(encoded.freeze(), format);
212            StoredRecord::encrypted(encrypted, metered_size)
213        }
214        (Record::Envelope(envelope), EncryptionSpec::Aes256Gcm(key)) => {
215            let format = EncryptedRecordFormat::Aes256GcmV1;
216            let (mut encoded, payload_start) = prep_encryption_buffer(&envelope, format);
217            let (prefix, payload) = encoded.split_at_mut(payload_start);
218            let nonce = aes_gcm::Nonce::from_slice(&prefix[FORMAT_ID_LEN..]);
219            let tag = Aes256Gcm::new(aes_gcm::Key::<Aes256Gcm>::from_slice(key.expose_secret()))
220                .encrypt_in_place_detached(nonce, aad, payload)
221                .expect("AES-256-GCM encryption should not fail on size validation");
222            encoded.put_slice(tag.as_ref());
223
224            let encrypted = EncryptedRecord::new(encoded.freeze(), format);
225            StoredRecord::encrypted(encrypted, metered_size)
226        }
227    };
228    Metered::with_size(metered_size, record)
229}
230
231fn prep_encryption_buffer(
232    envelope: &super::EnvelopeRecord,
233    format: EncryptedRecordFormat,
234) -> (BytesMut, usize) {
235    let payload_start = FORMAT_ID_LEN + format.nonce_len();
236    let mut encoded =
237        BytesMut::with_capacity(payload_start + envelope.encoded_size() + format.tag_len());
238    encoded.put_u8(format.format_id());
239    format.put_random_nonce(&mut encoded);
240    envelope.encode_into(&mut encoded);
241    (encoded, payload_start)
242}
243
244impl TryFrom<Bytes> for EncryptedRecord {
245    type Error = RecordDecodeError;
246
247    fn try_from(encoded: Bytes) -> Result<Self, Self::Error> {
248        if encoded.len() < FORMAT_ID_LEN {
249            return Err(RecordDecodeError::Truncated("EncryptedRecordFormatId"));
250        }
251
252        let format = EncryptedRecordFormat::try_from_format_id(encoded[0])?;
253        let nonce_len = format.nonce_len();
254        let tag_len = format.tag_len();
255        if encoded.len() < FORMAT_ID_LEN + nonce_len + tag_len {
256            return Err(RecordDecodeError::Truncated("EncryptedRecordFrame"));
257        }
258
259        Ok(Self::new(encoded, format))
260    }
261}
262
263pub fn decrypt_stored_record(
264    record: StoredRecord,
265    encryption: &EncryptionSpec,
266    aad: &[u8],
267) -> Result<Metered<Record>, RecordDecryptionError> {
268    match record {
269        StoredRecord::Plaintext(record @ Record::Command(_)) => Ok(record.metered()),
270        StoredRecord::Plaintext(record @ Record::Envelope(_)) => match encryption {
271            EncryptionSpec::Plain => Ok(record.metered()),
272            EncryptionSpec::Aegis256(_) => Err(RecordDecryptionError::AlgorithmMismatch {
273                expected: Some(EncryptionAlgorithm::Aegis256),
274                actual: None,
275            }),
276            EncryptionSpec::Aes256Gcm(_) => Err(RecordDecryptionError::AlgorithmMismatch {
277                expected: Some(EncryptionAlgorithm::Aes256Gcm),
278                actual: None,
279            }),
280        },
281        StoredRecord::Encrypted {
282            metered_size,
283            record: encrypted,
284        } => {
285            let plaintext = decrypt_payload(encrypted, encryption, aad)?;
286            let record = Record::Envelope(plaintext.try_into()?);
287            let actual_metered_size = record.metered_size();
288            if metered_size != actual_metered_size {
289                return Err(RecordDecryptionError::MeteredSizeMismatch {
290                    stored: metered_size,
291                    actual: actual_metered_size,
292                });
293            }
294            Ok(Metered::with_size(metered_size, record))
295        }
296    }
297}
298
299fn decrypt_payload(
300    record: EncryptedRecord,
301    encryption: &EncryptionSpec,
302    aad: &[u8],
303) -> Result<Bytes, RecordDecryptionError> {
304    let format = record.format;
305    let (mut encoded, payload_start, payload_end) = decryption_layout(record, format)?;
306    let plaintext_len = payload_end - payload_start;
307
308    match (format, encryption) {
309        (EncryptedRecordFormat::Aegis256V1, EncryptionSpec::Aegis256(key)) => {
310            let (prefix, payload_and_tag) = encoded.split_at_mut(payload_start);
311            let nonce: &[u8; 32] = prefix
312                .get(FORMAT_ID_LEN..)
313                .ok_or(RecordDecryptionError::MalformedEncryptedRecord)?
314                .try_into()
315                .map_err(|_| RecordDecryptionError::MalformedEncryptedRecord)?;
316            let (ciphertext, tag) = payload_and_tag.split_at_mut(plaintext_len);
317            let tag: &[u8; 16] = tag
318                .as_ref()
319                .try_into()
320                .map_err(|_| RecordDecryptionError::MalformedEncryptedRecord)?;
321            Aegis256::<16>::new(key.expose_secret(), nonce)
322                .decrypt_in_place(ciphertext, tag, aad)
323                .map_err(|_| RecordDecryptionError::AuthenticationFailed)?;
324            Ok(decryption_finish(encoded, payload_start, plaintext_len))
325        }
326        (EncryptedRecordFormat::Aegis256V1, EncryptionSpec::Plain) => {
327            Err(RecordDecryptionError::AlgorithmMismatch {
328                expected: None,
329                actual: Some(EncryptionAlgorithm::Aegis256),
330            })
331        }
332        (EncryptedRecordFormat::Aegis256V1, EncryptionSpec::Aes256Gcm(_)) => {
333            Err(RecordDecryptionError::AlgorithmMismatch {
334                expected: Some(EncryptionAlgorithm::Aes256Gcm),
335                actual: Some(EncryptionAlgorithm::Aegis256),
336            })
337        }
338        (EncryptedRecordFormat::Aes256GcmV1, EncryptionSpec::Aes256Gcm(key)) => {
339            let cipher = Aes256Gcm::new(aes_gcm::Key::<Aes256Gcm>::from_slice(key.expose_secret()));
340            let (prefix, payload_and_tag) = encoded.split_at_mut(payload_start);
341            let nonce: &[u8; 12] = prefix
342                .get(FORMAT_ID_LEN..)
343                .ok_or(RecordDecryptionError::MalformedEncryptedRecord)?
344                .try_into()
345                .map_err(|_| RecordDecryptionError::MalformedEncryptedRecord)?;
346            let nonce = aes_gcm::Nonce::from_slice(nonce);
347            let (ciphertext, tag) = payload_and_tag.split_at_mut(plaintext_len);
348            let tag: &[u8; 16] = tag
349                .as_ref()
350                .try_into()
351                .map_err(|_| RecordDecryptionError::MalformedEncryptedRecord)?;
352            let tag = aes_gcm::Tag::from_slice(tag);
353            cipher
354                .decrypt_in_place_detached(nonce, aad, ciphertext, tag)
355                .map_err(|_| RecordDecryptionError::AuthenticationFailed)?;
356            Ok(decryption_finish(encoded, payload_start, plaintext_len))
357        }
358        (EncryptedRecordFormat::Aes256GcmV1, EncryptionSpec::Plain) => {
359            Err(RecordDecryptionError::AlgorithmMismatch {
360                expected: None,
361                actual: Some(EncryptionAlgorithm::Aes256Gcm),
362            })
363        }
364        (EncryptedRecordFormat::Aes256GcmV1, EncryptionSpec::Aegis256(_)) => {
365            Err(RecordDecryptionError::AlgorithmMismatch {
366                expected: Some(EncryptionAlgorithm::Aegis256),
367                actual: Some(EncryptionAlgorithm::Aes256Gcm),
368            })
369        }
370    }
371}
372
373fn decryption_layout(
374    record: EncryptedRecord,
375    format: EncryptedRecordFormat,
376) -> Result<(BytesMut, usize, usize), RecordDecryptionError> {
377    let payload_start = FORMAT_ID_LEN + format.nonce_len();
378    let payload_end = record
379        .encoded
380        .len()
381        .checked_sub(format.tag_len())
382        .ok_or(RecordDecryptionError::MalformedEncryptedRecord)?;
383    if payload_start > payload_end {
384        return Err(RecordDecryptionError::MalformedEncryptedRecord);
385    }
386    Ok((record.into_mut_encoded(), payload_start, payload_end))
387}
388
389fn decryption_finish(mut encoded: BytesMut, payload_start: usize, plaintext_len: usize) -> Bytes {
390    let _ = encoded.split_to(payload_start);
391    encoded.truncate(plaintext_len);
392    encoded.freeze()
393}
394
395#[cfg(test)]
396mod tests {
397    use bytes::Bytes;
398    use rstest::rstest;
399
400    use super::*;
401    use crate::record::{CommandRecord, EnvelopeRecord, Header, MeteredExt};
402
403    const TEST_KEY: [u8; 32] = [0x42; 32];
404    const OTHER_TEST_KEY: [u8; 32] = [0x99; 32];
405
406    fn test_encryption(alg: EncryptionAlgorithm) -> EncryptionSpec {
407        match alg {
408            EncryptionAlgorithm::Aegis256 => EncryptionSpec::aegis256(TEST_KEY),
409            EncryptionAlgorithm::Aes256Gcm => EncryptionSpec::aes256_gcm(TEST_KEY),
410        }
411    }
412
413    fn other_test_encryption(alg: EncryptionAlgorithm) -> EncryptionSpec {
414        match alg {
415            EncryptionAlgorithm::Aegis256 => EncryptionSpec::aegis256(OTHER_TEST_KEY),
416            EncryptionAlgorithm::Aes256Gcm => EncryptionSpec::aes256_gcm(OTHER_TEST_KEY),
417        }
418    }
419
420    fn encrypt_test_record(
421        plaintext: EnvelopeRecord,
422        alg: EncryptionAlgorithm,
423        aad: &[u8],
424    ) -> EncryptedRecord {
425        let stored = encrypt_record(
426            Record::Envelope(plaintext).metered(),
427            &test_encryption(alg),
428            aad,
429        )
430        .into_inner();
431        let StoredRecord::Encrypted { record, .. } = stored else {
432            panic!("expected encrypted envelope record");
433        };
434        record
435    }
436
437    fn make_encrypted_record(
438        format: EncryptedRecordFormat,
439        nonce: impl AsRef<[u8]>,
440        ciphertext: impl AsRef<[u8]>,
441        tag: impl AsRef<[u8]>,
442    ) -> EncryptedRecord {
443        let nonce = nonce.as_ref();
444        let ciphertext = ciphertext.as_ref();
445        let tag = tag.as_ref();
446
447        assert_eq!(nonce.len(), format.nonce_len());
448        assert_eq!(tag.len(), format.tag_len());
449
450        let mut encoded =
451            BytesMut::with_capacity(FORMAT_ID_LEN + nonce.len() + ciphertext.len() + tag.len());
452        encoded.put_u8(format.format_id());
453        encoded.put_slice(nonce);
454        encoded.put_slice(ciphertext);
455        encoded.put_slice(tag);
456
457        EncryptedRecord::new(encoded.freeze(), format)
458    }
459
460    fn aad() -> [u8; 32] {
461        [0xA5; 32]
462    }
463
464    fn make_envelope(headers: Vec<Header>, body: Bytes) -> EnvelopeRecord {
465        EnvelopeRecord::try_from_parts(headers, body).unwrap()
466    }
467
468    fn make_plaintext_envelope(headers: Vec<Header>, body: Bytes) -> Record {
469        Record::Envelope(make_envelope(headers, body))
470    }
471
472    fn make_encrypted_stored_record(
473        encryption: &EncryptionSpec,
474        headers: Vec<Header>,
475        body: Bytes,
476        aad: &[u8],
477    ) -> StoredRecord {
478        let stored = encrypt_record(
479            make_plaintext_envelope(headers, body).metered(),
480            encryption,
481            aad,
482        )
483        .into_inner();
484        let StoredRecord::Encrypted { .. } = &stored else {
485            panic!("plain encryption should not produce an encrypted record");
486        };
487        stored
488    }
489
490    #[rstest]
491    #[case::aegis_unique(EncryptionAlgorithm::Aegis256, false)]
492    #[case::aegis_shared(EncryptionAlgorithm::Aegis256, true)]
493    #[case::aes_unique(EncryptionAlgorithm::Aes256Gcm, false)]
494    #[case::aes_shared(EncryptionAlgorithm::Aes256Gcm, true)]
495    fn encrypted_payload_roundtrips(
496        #[case] algorithm: EncryptionAlgorithm,
497        #[case] shared_encoded_record_buffer: bool,
498    ) {
499        let headers = vec![Header {
500            name: Bytes::from_static(b"x-test"),
501            value: Bytes::from_static(b"hello"),
502        }];
503        let body = Bytes::from_static(b"secret payload");
504
505        let aad = aad();
506        let plaintext = make_envelope(headers.clone(), body.clone());
507        let encryption = test_encryption(algorithm);
508        let encrypted_record = encrypt_test_record(plaintext, algorithm, &aad);
509        let encrypted_record = if shared_encoded_record_buffer {
510            let shared = encrypted_record.encoded.clone();
511            EncryptedRecord::try_from(shared).unwrap()
512        } else {
513            encrypted_record
514        };
515        let decrypted = decrypt_payload(encrypted_record, &encryption, &aad).unwrap();
516        let (out_headers, out_body) = EnvelopeRecord::try_from(decrypted).unwrap().into_parts();
517
518        assert_eq!(out_headers, headers);
519        assert_eq!(out_body, body);
520    }
521
522    #[rstest]
523    #[case(EncryptionAlgorithm::Aegis256)]
524    #[case(EncryptionAlgorithm::Aes256Gcm)]
525    fn wrong_key_fails(#[case] algorithm: EncryptionAlgorithm) {
526        let aad = aad();
527        let plaintext = make_envelope(vec![], Bytes::from_static(b"data"));
528        let encrypted_record = encrypt_test_record(plaintext, algorithm, &aad);
529        let result = decrypt_payload(encrypted_record, &other_test_encryption(algorithm), &aad);
530        assert!(matches!(
531            result,
532            Err(RecordDecryptionError::AuthenticationFailed)
533        ));
534    }
535
536    #[test]
537    fn empty_body_fails() {
538        let result = EncryptedRecord::try_from(Bytes::new());
539        assert!(matches!(
540            result,
541            Err(RecordDecodeError::Truncated("EncryptedRecordFormatId"))
542        ));
543    }
544
545    #[test]
546    fn format_id_byte_present() {
547        let aad = aad();
548        let plaintext = make_envelope(vec![], Bytes::from_static(b"data"));
549        let encrypted_record = encrypt_test_record(plaintext, EncryptionAlgorithm::Aegis256, &aad);
550        let encoded = encrypted_record.to_bytes();
551        assert_eq!(encrypted_record.format, EncryptedRecordFormat::Aegis256V1);
552        assert_eq!(encrypted_record.algorithm(), EncryptionAlgorithm::Aegis256);
553        assert_eq!(encoded[0], 0x01);
554    }
555
556    #[test]
557    fn format_id_flip_detected() {
558        let aad = aad();
559        let plaintext = make_envelope(vec![], Bytes::from_static(b"data"));
560        let mut encoded_record =
561            encrypt_test_record(plaintext, EncryptionAlgorithm::Aegis256, &aad)
562                .to_bytes()
563                .to_vec();
564        assert_eq!(encoded_record[0], 0x01);
565        encoded_record[0] = 0x02;
566        let encrypted_record = EncryptedRecord::try_from(Bytes::from(encoded_record)).unwrap();
567        let result = decrypt_payload(
568            encrypted_record,
569            &test_encryption(EncryptionAlgorithm::Aegis256),
570            &aad,
571        );
572        assert!(matches!(
573            result,
574            Err(RecordDecryptionError::AlgorithmMismatch {
575                expected: Some(EncryptionAlgorithm::Aegis256),
576                actual: Some(EncryptionAlgorithm::Aes256Gcm),
577            })
578        ));
579    }
580
581    #[test]
582    fn wrong_aad_fails() {
583        let aad = aad();
584        let other_aad = [0x5A; 32];
585        let plaintext = make_envelope(vec![], Bytes::from_static(b"data"));
586        let encrypted_record = encrypt_test_record(plaintext, EncryptionAlgorithm::Aegis256, &aad);
587        let result = decrypt_payload(
588            encrypted_record,
589            &test_encryption(EncryptionAlgorithm::Aegis256),
590            &other_aad,
591        );
592        assert!(matches!(
593            result,
594            Err(RecordDecryptionError::AuthenticationFailed)
595        ));
596    }
597
598    #[test]
599    fn malformed_encrypted_record_layout_returns_error_instead_of_panicking() {
600        let aad = aad();
601        let record = EncryptedRecord {
602            encoded: Bytes::from_static(b"\x01short"),
603            format: EncryptedRecordFormat::Aegis256V1,
604        };
605
606        let result = decrypt_payload(
607            record,
608            &test_encryption(EncryptionAlgorithm::Aegis256),
609            &aad,
610        );
611
612        assert!(matches!(
613            result,
614            Err(RecordDecryptionError::MalformedEncryptedRecord)
615        ));
616    }
617
618    #[test]
619    fn encrypted_record_roundtrips_aes256gcm() {
620        let record = make_encrypted_record(
621            EncryptedRecordFormat::Aes256GcmV1,
622            Bytes::from_static(b"0123456789ab"),
623            Bytes::from_static(b"ciphertext"),
624            Bytes::from_static(b"0123456789abcdef"),
625        );
626
627        let bytes = record.to_bytes();
628        let decoded = EncryptedRecord::try_from(bytes).unwrap();
629
630        assert_eq!(decoded, record);
631        assert_eq!(decoded.format, EncryptedRecordFormat::Aes256GcmV1);
632        assert_eq!(decoded.encoded[0], FORMAT_ID_AES256GCM_V1);
633        assert_eq!(decoded.nonce(), b"0123456789ab");
634        assert_eq!(decoded.ciphertext(), b"ciphertext");
635        assert_eq!(decoded.tag(), b"0123456789abcdef");
636    }
637
638    #[test]
639    fn rejects_invalid_format_id() {
640        let err = EncryptedRecord::try_from(Bytes::from_static(b"\xFFpayload")).unwrap_err();
641        assert_eq!(
642            err,
643            RecordDecodeError::InvalidValue(
644                "EncryptedRecord",
645                "invalid encrypted record format id"
646            )
647        );
648    }
649
650    #[test]
651    fn rejects_truncated_layout() {
652        let err = EncryptedRecord::try_from(Bytes::from_static(b"\x01tiny")).unwrap_err();
653        assert_eq!(err, RecordDecodeError::Truncated("EncryptedRecordFrame"));
654    }
655
656    #[test]
657    fn encrypt_record_encrypts_envelope_records() {
658        let aad = aad();
659        let encryption = test_encryption(EncryptionAlgorithm::Aegis256);
660        let headers = vec![Header {
661            name: Bytes::from_static(b"x-test"),
662            value: Bytes::from_static(b"hello"),
663        }];
664        let body = Bytes::from_static(b"secret payload");
665        let record = make_plaintext_envelope(headers.clone(), body.clone()).metered();
666
667        let stored = encrypt_record(record, &encryption, &aad).into_inner();
668        let StoredRecord::Encrypted {
669            record: envelope, ..
670        } = &stored
671        else {
672            panic!("expected encrypted envelope record");
673        };
674        assert_eq!(envelope.format, EncryptedRecordFormat::Aegis256V1);
675        assert_eq!(envelope.algorithm(), EncryptionAlgorithm::Aegis256);
676
677        let decrypted = decrypt_stored_record(stored, &encryption, &aad).unwrap();
678        let Record::Envelope(record) = decrypted.into_inner() else {
679            panic!("expected envelope record");
680        };
681        assert_eq!(record.headers(), headers.as_slice());
682        assert_eq!(record.body().as_ref(), body.as_ref());
683    }
684
685    #[test]
686    fn decrypt_stored_record_preserves_plaintext_command_records() {
687        let token: crate::record::FencingToken = "fence-test".parse().unwrap();
688        let record = StoredRecord::Plaintext(Record::Command(CommandRecord::Fence(token.clone())));
689
690        let decrypted = decrypt_stored_record(
691            record,
692            &test_encryption(EncryptionAlgorithm::Aegis256),
693            &aad(),
694        )
695        .unwrap();
696
697        let Record::Command(record) = decrypted.into_inner() else {
698            panic!("expected command record");
699        };
700        assert_eq!(record, CommandRecord::Fence(token));
701    }
702
703    #[test]
704    fn decrypt_stored_record_decrypts_encrypted_records() {
705        let aad = aad();
706        let record = make_encrypted_stored_record(
707            &test_encryption(EncryptionAlgorithm::Aegis256),
708            vec![Header {
709                name: Bytes::from_static(b"x-test"),
710                value: Bytes::from_static(b"hello"),
711            }],
712            Bytes::from_static(b"secret payload"),
713            &aad,
714        );
715
716        let decrypted = decrypt_stored_record(
717            record,
718            &test_encryption(EncryptionAlgorithm::Aegis256),
719            &aad,
720        )
721        .unwrap();
722
723        let Record::Envelope(record) = decrypted.into_inner() else {
724            panic!("expected envelope record");
725        };
726        assert_eq!(record.headers().len(), 1);
727        assert_eq!(record.headers()[0].name.as_ref(), b"x-test");
728        assert_eq!(record.headers()[0].value.as_ref(), b"hello");
729        assert_eq!(record.body().as_ref(), b"secret payload");
730    }
731
732    #[test]
733    fn decrypt_stored_record_plain_rejects_encrypted_records() {
734        let aad = aad();
735        let record = make_encrypted_stored_record(
736            &test_encryption(EncryptionAlgorithm::Aegis256),
737            vec![],
738            Bytes::from_static(b"secret payload"),
739            &aad,
740        );
741
742        let result = decrypt_stored_record(record, &EncryptionSpec::Plain, &aad);
743
744        assert!(matches!(
745            result,
746            Err(RecordDecryptionError::AlgorithmMismatch {
747                expected: None,
748                actual: Some(EncryptionAlgorithm::Aegis256),
749            })
750        ));
751    }
752
753    #[test]
754    fn decode_stored_record_rejects_encrypted_metered_size_mismatch() {
755        let aad = aad();
756        let stored = make_encrypted_stored_record(
757            &test_encryption(EncryptionAlgorithm::Aegis256),
758            vec![Header {
759                name: Bytes::from_static(b"x-test"),
760                value: Bytes::from_static(b"hello"),
761            }],
762            Bytes::from_static(b"secret payload"),
763            &aad,
764        );
765        let StoredRecord::Encrypted {
766            metered_size,
767            record,
768        } = stored
769        else {
770            panic!("expected encrypted stored record");
771        };
772
773        let result = decrypt_stored_record(
774            StoredRecord::encrypted(record, metered_size + 1),
775            &test_encryption(EncryptionAlgorithm::Aegis256),
776            &aad,
777        );
778
779        assert!(matches!(
780            result,
781            Err(RecordDecryptionError::MeteredSizeMismatch {
782                stored,
783                actual
784            }) if stored == metered_size + 1 && actual == metered_size
785        ));
786    }
787}