Skip to main content

foctet_core/
body.rs

1use chacha20poly1305::{
2    KeyInit, XChaCha20Poly1305, XNonce,
3    aead::{Aead, Payload},
4};
5use hkdf::Hkdf;
6use rand_core::{OsRng, RngCore};
7use sha2::Sha256;
8use thiserror::Error;
9use x25519_dalek::{PublicKey, StaticSecret};
10use zeroize::Zeroizing;
11
12/// `application/foctet` body-envelope magic bytes.
13pub const BODY_MAGIC: [u8; 8] = *b"FOCTETHB";
14/// Body-envelope wire version `v0`.
15pub const BODY_VERSION_V0: u8 = 0x01;
16/// Profile `0x01` (`X25519 + HKDF-SHA256 + XChaCha20-Poly1305`).
17pub const BODY_PROFILE_V0: u8 = 0x01;
18/// X25519 public key length in bytes.
19pub const X25519_PUBLIC_KEY_LEN: usize = 32;
20/// XChaCha20-Poly1305 nonce length in bytes.
21pub const XCHACHA_NONCE_LEN: usize = 24;
22const CONTENT_KEY_LEN: usize = 32;
23const TAG_LEN: usize = 16;
24const WRAP_INFO_LABEL: &[u8] = b"foctet body wrap v0";
25
26/// Parser and encoder hardening limits for body envelopes.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct BodyEnvelopeLimits {
29    /// Maximum parsed header length in bytes.
30    pub max_header_bytes: usize,
31    /// Maximum number of recipient entries allowed in one envelope.
32    pub max_recipients: usize,
33    /// Maximum recipient key identifier length in bytes.
34    pub max_key_id_len: usize,
35    /// Maximum wrapped content-key length in bytes.
36    pub max_wrapped_key_len: usize,
37    /// Maximum payload ciphertext length in bytes.
38    pub max_payload_len: usize,
39}
40
41impl Default for BodyEnvelopeLimits {
42    fn default() -> Self {
43        Self {
44            max_header_bytes: 64 * 1024,
45            max_recipients: 16,
46            max_key_id_len: 512,
47            max_wrapped_key_len: 512,
48            max_payload_len: 64 * 1024 * 1024,
49        }
50    }
51}
52
53/// Envelope parser/sealer error type for `application/foctet`.
54#[derive(Debug, Error, Clone, Eq, PartialEq)]
55pub enum BodyEnvelopeError {
56    /// Envelope magic bytes are invalid.
57    #[error("invalid body-envelope magic")]
58    InvalidMagic,
59    /// Body-envelope version is unsupported.
60    #[error("unsupported body-envelope version: {0}")]
61    UnsupportedVersion(u8),
62    /// Body-envelope profile is unsupported.
63    #[error("unsupported body-envelope profile: {0}")]
64    UnsupportedProfile(u8),
65    /// Input bytes are truncated.
66    #[error("truncated body-envelope input")]
67    Truncated,
68    /// Header bytes are malformed or inconsistent.
69    #[error("invalid body-envelope header: {0}")]
70    InvalidHeader(&'static str),
71    /// A configured parser or encoder limit was exceeded.
72    #[error("body-envelope limit exceeded: {0}")]
73    LimitExceeded(&'static str),
74    /// No matching recipient entry could be used.
75    #[error("recipient not found")]
76    RecipientNotFound,
77    /// Content-key unwrap failed for a selected recipient entry.
78    #[error("content-key unwrap failed")]
79    KeyUnwrapFailed,
80    /// Payload decryption failed.
81    #[error("payload decryption failed")]
82    DecryptFailed,
83    /// Payload encryption failed.
84    #[error("payload encryption failed")]
85    EncryptFailed,
86    /// HKDF expansion failed.
87    #[error("hkdf expand failed")]
88    Hkdf,
89}
90
91#[derive(Clone, Debug)]
92struct RecipientEntry {
93    key_id: Vec<u8>,
94    wrapped_key: Vec<u8>,
95}
96
97#[derive(Clone, Debug)]
98struct ParsedEnvelope<'a> {
99    header_bytes: &'a [u8],
100    payload_ciphertext: &'a [u8],
101    ephemeral_public: [u8; X25519_PUBLIC_KEY_LEN],
102    payload_nonce: [u8; XCHACHA_NONCE_LEN],
103    recipients: Vec<RecipientEntry>,
104}
105
106/// Seals plaintext bytes into an `application/foctet` v0 body envelope.
107pub fn seal_body(
108    plaintext: &[u8],
109    recipient_public_key: [u8; 32],
110    recipient_key_id: &[u8],
111) -> Result<Vec<u8>, BodyEnvelopeError> {
112    seal_body_with_limits(
113        plaintext,
114        recipient_public_key,
115        recipient_key_id,
116        &BodyEnvelopeLimits::default(),
117    )
118}
119
120/// Seals plaintext bytes with explicit limits for defensive encoding.
121pub fn seal_body_with_limits(
122    plaintext: &[u8],
123    recipient_public_key: [u8; 32],
124    recipient_key_id: &[u8],
125    limits: &BodyEnvelopeLimits,
126) -> Result<Vec<u8>, BodyEnvelopeError> {
127    if recipient_key_id.is_empty() {
128        return Err(BodyEnvelopeError::InvalidHeader("empty recipient key id"));
129    }
130    if recipient_key_id.len() > limits.max_key_id_len {
131        return Err(BodyEnvelopeError::LimitExceeded("key_id_len"));
132    }
133
134    let payload_len = plaintext
135        .len()
136        .checked_add(TAG_LEN)
137        .ok_or(BodyEnvelopeError::LimitExceeded("payload_len overflow"))?;
138    if payload_len > limits.max_payload_len {
139        return Err(BodyEnvelopeError::LimitExceeded("payload_len"));
140    }
141
142    let mut content_key = Zeroizing::new([0u8; CONTENT_KEY_LEN]);
143    OsRng.fill_bytes(&mut content_key[..]);
144
145    let mut payload_nonce = [0u8; XCHACHA_NONCE_LEN];
146    OsRng.fill_bytes(&mut payload_nonce);
147
148    let eph_priv = StaticSecret::random_from_rng(OsRng);
149    let eph_pub = PublicKey::from(&eph_priv).to_bytes();
150
151    let wrapped_key = wrap_content_key(
152        &content_key,
153        recipient_public_key,
154        eph_priv,
155        eph_pub,
156        recipient_key_id,
157    )?;
158
159    if wrapped_key.len() > limits.max_wrapped_key_len {
160        return Err(BodyEnvelopeError::LimitExceeded("wrapped_key_len"));
161    }
162
163    let header = encode_header(
164        &eph_pub,
165        &payload_nonce,
166        recipient_key_id,
167        &wrapped_key,
168        payload_len,
169        limits,
170    )?;
171
172    if header.len() > limits.max_header_bytes {
173        return Err(BodyEnvelopeError::LimitExceeded("header_len"));
174    }
175
176    let cipher = XChaCha20Poly1305::new_from_slice(&content_key[..])
177        .map_err(|_| BodyEnvelopeError::EncryptFailed)?;
178    let payload_ciphertext = cipher
179        .encrypt(
180            XNonce::from_slice(&payload_nonce),
181            Payload {
182                msg: plaintext,
183                aad: &header,
184            },
185        )
186        .map_err(|_| BodyEnvelopeError::EncryptFailed)?;
187
188    let mut out = Vec::with_capacity(
189        header
190            .len()
191            .checked_add(payload_ciphertext.len())
192            .ok_or(BodyEnvelopeError::LimitExceeded("envelope_len overflow"))?,
193    );
194    out.extend_from_slice(&header);
195    out.extend_from_slice(&payload_ciphertext);
196    Ok(out)
197}
198
199/// Opens an `application/foctet` v0 body envelope with default limits.
200pub fn open_body(
201    envelope: &[u8],
202    recipient_secret_key: [u8; 32],
203) -> Result<Vec<u8>, BodyEnvelopeError> {
204    open_body_with_limits(
205        envelope,
206        recipient_secret_key,
207        &BodyEnvelopeLimits::default(),
208    )
209}
210
211/// Opens an `application/foctet` v0 body envelope with explicit parser limits.
212pub fn open_body_with_limits(
213    envelope: &[u8],
214    recipient_secret_key: [u8; 32],
215    limits: &BodyEnvelopeLimits,
216) -> Result<Vec<u8>, BodyEnvelopeError> {
217    let parsed = parse_envelope(envelope, limits)?;
218
219    for recipient in &parsed.recipients {
220        let content_key = match unwrap_content_key(
221            &recipient.wrapped_key,
222            &recipient.key_id,
223            recipient_secret_key,
224            parsed.ephemeral_public,
225        ) {
226            Ok(key) => key,
227            Err(BodyEnvelopeError::KeyUnwrapFailed) => continue,
228            Err(err) => return Err(err),
229        };
230
231        let cipher = XChaCha20Poly1305::new_from_slice(&content_key[..])
232            .map_err(|_| BodyEnvelopeError::DecryptFailed)?;
233
234        let plain = cipher
235            .decrypt(
236                XNonce::from_slice(&parsed.payload_nonce),
237                Payload {
238                    msg: parsed.payload_ciphertext,
239                    aad: parsed.header_bytes,
240                },
241            )
242            .map_err(|_| BodyEnvelopeError::DecryptFailed)?;
243
244        return Ok(plain);
245    }
246
247    Err(BodyEnvelopeError::RecipientNotFound)
248}
249
250/// Opens an envelope for a specific recipient key identifier.
251pub fn open_body_for_key_id(
252    envelope: &[u8],
253    recipient_secret_key: [u8; 32],
254    recipient_key_id: &[u8],
255) -> Result<Vec<u8>, BodyEnvelopeError> {
256    open_body_for_key_id_with_limits(
257        envelope,
258        recipient_secret_key,
259        recipient_key_id,
260        &BodyEnvelopeLimits::default(),
261    )
262}
263
264/// Opens an envelope for a specific recipient key identifier using explicit limits.
265pub fn open_body_for_key_id_with_limits(
266    envelope: &[u8],
267    recipient_secret_key: [u8; 32],
268    recipient_key_id: &[u8],
269    limits: &BodyEnvelopeLimits,
270) -> Result<Vec<u8>, BodyEnvelopeError> {
271    let parsed = parse_envelope(envelope, limits)?;
272
273    let entry = parsed
274        .recipients
275        .iter()
276        .find(|entry| entry.key_id.as_slice() == recipient_key_id)
277        .ok_or(BodyEnvelopeError::RecipientNotFound)?;
278
279    let content_key = unwrap_content_key(
280        &entry.wrapped_key,
281        &entry.key_id,
282        recipient_secret_key,
283        parsed.ephemeral_public,
284    )?;
285
286    let cipher = XChaCha20Poly1305::new_from_slice(&content_key[..])
287        .map_err(|_| BodyEnvelopeError::DecryptFailed)?;
288
289    cipher
290        .decrypt(
291            XNonce::from_slice(&parsed.payload_nonce),
292            Payload {
293                msg: parsed.payload_ciphertext,
294                aad: parsed.header_bytes,
295            },
296        )
297        .map_err(|_| BodyEnvelopeError::DecryptFailed)
298}
299
300fn parse_envelope<'a>(
301    envelope: &'a [u8],
302    limits: &BodyEnvelopeLimits,
303) -> Result<ParsedEnvelope<'a>, BodyEnvelopeError> {
304    let mut cur = 0usize;
305
306    let magic = take(envelope, &mut cur, BODY_MAGIC.len())?;
307    if magic != BODY_MAGIC {
308        return Err(BodyEnvelopeError::InvalidMagic);
309    }
310
311    let version = *take(envelope, &mut cur, 1)?
312        .first()
313        .ok_or(BodyEnvelopeError::Truncated)?;
314    if version != BODY_VERSION_V0 {
315        return Err(BodyEnvelopeError::UnsupportedVersion(version));
316    }
317
318    let profile = *take(envelope, &mut cur, 1)?
319        .first()
320        .ok_or(BodyEnvelopeError::Truncated)?;
321    if profile != BODY_PROFILE_V0 {
322        return Err(BodyEnvelopeError::UnsupportedProfile(profile));
323    }
324
325    let flags = *take(envelope, &mut cur, 1)?
326        .first()
327        .ok_or(BodyEnvelopeError::Truncated)?;
328    if flags != 0 {
329        return Err(BodyEnvelopeError::InvalidHeader("unknown flags"));
330    }
331
332    let eph_len = *take(envelope, &mut cur, 1)?
333        .first()
334        .ok_or(BodyEnvelopeError::Truncated)? as usize;
335    if eph_len != X25519_PUBLIC_KEY_LEN {
336        return Err(BodyEnvelopeError::InvalidHeader(
337            "invalid ephemeral_public_key_len",
338        ));
339    }
340
341    let header_len = decode_varint(envelope, &mut cur)?;
342    let recipient_count = decode_varint(envelope, &mut cur)?;
343    let payload_len = decode_varint(envelope, &mut cur)?;
344
345    let header_len = to_usize(header_len, "header_len")?;
346    let recipient_count = to_usize(recipient_count, "recipient_count")?;
347    let payload_len = to_usize(payload_len, "payload_len")?;
348
349    if header_len > limits.max_header_bytes {
350        return Err(BodyEnvelopeError::LimitExceeded("header_len"));
351    }
352    if recipient_count > limits.max_recipients {
353        return Err(BodyEnvelopeError::LimitExceeded("recipient_count"));
354    }
355    if payload_len > limits.max_payload_len {
356        return Err(BodyEnvelopeError::LimitExceeded("payload_len"));
357    }
358    if header_len > envelope.len() {
359        return Err(BodyEnvelopeError::Truncated);
360    }
361
362    let min_tail = eph_len
363        .checked_add(XCHACHA_NONCE_LEN)
364        .ok_or(BodyEnvelopeError::InvalidHeader("header length overflow"))?;
365    if cur
366        .checked_add(min_tail)
367        .ok_or(BodyEnvelopeError::InvalidHeader("header length overflow"))?
368        > header_len
369    {
370        return Err(BodyEnvelopeError::InvalidHeader("header_len too small"));
371    }
372
373    let mut ephemeral_public = [0u8; X25519_PUBLIC_KEY_LEN];
374    ephemeral_public.copy_from_slice(take(envelope, &mut cur, X25519_PUBLIC_KEY_LEN)?);
375
376    let mut payload_nonce = [0u8; XCHACHA_NONCE_LEN];
377    payload_nonce.copy_from_slice(take(envelope, &mut cur, XCHACHA_NONCE_LEN)?);
378
379    let mut recipients = Vec::new();
380    for _ in 0..recipient_count {
381        if cur >= header_len {
382            return Err(BodyEnvelopeError::Truncated);
383        }
384
385        let key_id_len = to_usize(decode_varint(envelope, &mut cur)?, "key_id_len")?;
386        let wrapped_key_len = to_usize(decode_varint(envelope, &mut cur)?, "wrapped_key_len")?;
387
388        if key_id_len == 0 {
389            return Err(BodyEnvelopeError::InvalidHeader("empty key_id"));
390        }
391        if key_id_len > limits.max_key_id_len {
392            return Err(BodyEnvelopeError::LimitExceeded("key_id_len"));
393        }
394        if wrapped_key_len > limits.max_wrapped_key_len {
395            return Err(BodyEnvelopeError::LimitExceeded("wrapped_key_len"));
396        }
397        if wrapped_key_len != CONTENT_KEY_LEN + TAG_LEN {
398            return Err(BodyEnvelopeError::InvalidHeader(
399                "invalid wrapped_key_len for v0",
400            ));
401        }
402
403        let end = cur
404            .checked_add(key_id_len)
405            .and_then(|v| v.checked_add(wrapped_key_len))
406            .ok_or(BodyEnvelopeError::InvalidHeader("recipient entry overflow"))?;
407        if end > header_len || end > envelope.len() {
408            return Err(BodyEnvelopeError::Truncated);
409        }
410
411        let key_id = take(envelope, &mut cur, key_id_len)?.to_vec();
412        let wrapped_key = take(envelope, &mut cur, wrapped_key_len)?.to_vec();
413        recipients.push(RecipientEntry {
414            key_id,
415            wrapped_key,
416        });
417    }
418
419    if recipients.is_empty() {
420        return Err(BodyEnvelopeError::InvalidHeader(
421            "recipient_count must be >= 1",
422        ));
423    }
424
425    if cur != header_len {
426        return Err(BodyEnvelopeError::InvalidHeader(
427            "unexpected trailing header bytes",
428        ));
429    }
430
431    let expected_total = header_len
432        .checked_add(payload_len)
433        .ok_or(BodyEnvelopeError::InvalidHeader("envelope length overflow"))?;
434    if envelope.len() < expected_total {
435        return Err(BodyEnvelopeError::Truncated);
436    }
437    if envelope.len() != expected_total {
438        return Err(BodyEnvelopeError::InvalidHeader(
439            "unexpected trailing body bytes",
440        ));
441    }
442
443    let payload_ciphertext = &envelope[header_len..expected_total];
444    if payload_ciphertext.len() < TAG_LEN {
445        return Err(BodyEnvelopeError::InvalidHeader(
446            "payload ciphertext too short",
447        ));
448    }
449
450    Ok(ParsedEnvelope {
451        header_bytes: &envelope[..header_len],
452        payload_ciphertext,
453        ephemeral_public,
454        payload_nonce,
455        recipients,
456    })
457}
458
459fn wrap_content_key(
460    content_key: &[u8; CONTENT_KEY_LEN],
461    recipient_public_key: [u8; 32],
462    eph_priv: StaticSecret,
463    eph_pub: [u8; 32],
464    key_id: &[u8],
465) -> Result<Vec<u8>, BodyEnvelopeError> {
466    let recipient = PublicKey::from(recipient_public_key);
467    let shared = Zeroizing::new(eph_priv.diffie_hellman(&recipient).to_bytes());
468
469    let (wrap_key, wrap_nonce) = derive_wrap_material(&shared, eph_pub, recipient_public_key)?;
470
471    let cipher = XChaCha20Poly1305::new_from_slice(&wrap_key[..])
472        .map_err(|_| BodyEnvelopeError::KeyUnwrapFailed)?;
473    cipher
474        .encrypt(
475            XNonce::from_slice(&wrap_nonce),
476            Payload {
477                msg: content_key,
478                aad: key_id,
479            },
480        )
481        .map_err(|_| BodyEnvelopeError::KeyUnwrapFailed)
482}
483
484fn unwrap_content_key(
485    wrapped_key: &[u8],
486    key_id: &[u8],
487    recipient_secret_key: [u8; 32],
488    ephemeral_public_key: [u8; 32],
489) -> Result<[u8; CONTENT_KEY_LEN], BodyEnvelopeError> {
490    let recipient_priv = StaticSecret::from(recipient_secret_key);
491    let recipient_public = PublicKey::from(&recipient_priv).to_bytes();
492    let eph_pub = PublicKey::from(ephemeral_public_key);
493
494    let shared = Zeroizing::new(recipient_priv.diffie_hellman(&eph_pub).to_bytes());
495    let (wrap_key, wrap_nonce) =
496        derive_wrap_material(&shared, ephemeral_public_key, recipient_public)?;
497
498    let cipher = XChaCha20Poly1305::new_from_slice(&wrap_key[..])
499        .map_err(|_| BodyEnvelopeError::KeyUnwrapFailed)?;
500    let unwrapped = cipher
501        .decrypt(
502            XNonce::from_slice(&wrap_nonce),
503            Payload {
504                msg: wrapped_key,
505                aad: key_id,
506            },
507        )
508        .map_err(|_| BodyEnvelopeError::KeyUnwrapFailed)?;
509
510    if unwrapped.len() != CONTENT_KEY_LEN {
511        return Err(BodyEnvelopeError::KeyUnwrapFailed);
512    }
513
514    let mut out = [0u8; CONTENT_KEY_LEN];
515    out.copy_from_slice(&unwrapped);
516    Ok(out)
517}
518
519fn derive_wrap_material(
520    shared_secret: &[u8; 32],
521    ephemeral_public_key: [u8; 32],
522    recipient_public_key: [u8; 32],
523) -> Result<(Zeroizing<[u8; 32]>, [u8; 24]), BodyEnvelopeError> {
524    let mut info = [0u8; WRAP_INFO_LABEL.len() + 64];
525    let label_len = WRAP_INFO_LABEL.len();
526    info[..label_len].copy_from_slice(WRAP_INFO_LABEL);
527    info[label_len..label_len + 32].copy_from_slice(&ephemeral_public_key);
528    info[label_len + 32..].copy_from_slice(&recipient_public_key);
529
530    let hk = Hkdf::<Sha256>::new(None, shared_secret);
531    let mut okm = Zeroizing::new([0u8; CONTENT_KEY_LEN + XCHACHA_NONCE_LEN]);
532    hk.expand(&info, &mut okm[..])
533        .map_err(|_| BodyEnvelopeError::Hkdf)?;
534
535    let mut wrap_key = Zeroizing::new([0u8; CONTENT_KEY_LEN]);
536    wrap_key.copy_from_slice(&okm[..CONTENT_KEY_LEN]);
537
538    let mut wrap_nonce = [0u8; XCHACHA_NONCE_LEN];
539    wrap_nonce.copy_from_slice(&okm[CONTENT_KEY_LEN..]);
540
541    Ok((wrap_key, wrap_nonce))
542}
543
544fn encode_header(
545    ephemeral_public_key: &[u8; 32],
546    payload_nonce: &[u8; XCHACHA_NONCE_LEN],
547    recipient_key_id: &[u8],
548    wrapped_key: &[u8],
549    payload_len: usize,
550    limits: &BodyEnvelopeLimits,
551) -> Result<Vec<u8>, BodyEnvelopeError> {
552    let payload_len_u64 = u64::try_from(payload_len)
553        .map_err(|_| BodyEnvelopeError::LimitExceeded("payload_len overflow"))?;
554
555    // Header length includes this varint itself. Solve by fixed-point iteration.
556    let mut header_len_guess = 0u64;
557    let mut converged = false;
558    let mut encoded = Vec::new();
559
560    for _ in 0..4 {
561        encoded.clear();
562        encoded.extend_from_slice(&BODY_MAGIC);
563        encoded.push(BODY_VERSION_V0);
564        encoded.push(BODY_PROFILE_V0);
565        encoded.push(0); // flags
566        encoded.push(X25519_PUBLIC_KEY_LEN as u8);
567        encode_varint(header_len_guess, &mut encoded);
568        encode_varint(1, &mut encoded);
569        encode_varint(payload_len_u64, &mut encoded);
570
571        encoded.extend_from_slice(ephemeral_public_key);
572        encoded.extend_from_slice(payload_nonce);
573        encode_varint(recipient_key_id.len() as u64, &mut encoded);
574        encode_varint(wrapped_key.len() as u64, &mut encoded);
575        encoded.extend_from_slice(recipient_key_id);
576        encoded.extend_from_slice(wrapped_key);
577
578        let actual = u64::try_from(encoded.len())
579            .map_err(|_| BodyEnvelopeError::LimitExceeded("header_len overflow"))?;
580        if actual == header_len_guess {
581            converged = true;
582            break;
583        }
584        header_len_guess = actual;
585    }
586
587    if !converged {
588        return Err(BodyEnvelopeError::InvalidHeader(
589            "header_len encoding did not converge",
590        ));
591    }
592
593    let final_len = encoded.len();
594    if final_len > limits.max_header_bytes {
595        return Err(BodyEnvelopeError::LimitExceeded("header_len"));
596    }
597
598    Ok(encoded)
599}
600
601fn to_usize(v: u64, field: &'static str) -> Result<usize, BodyEnvelopeError> {
602    usize::try_from(v).map_err(|_| BodyEnvelopeError::LimitExceeded(field))
603}
604
605fn take<'a>(input: &'a [u8], cur: &mut usize, len: usize) -> Result<&'a [u8], BodyEnvelopeError> {
606    let end = cur.checked_add(len).ok_or(BodyEnvelopeError::Truncated)?;
607    if end > input.len() {
608        return Err(BodyEnvelopeError::Truncated);
609    }
610    let out = &input[*cur..end];
611    *cur = end;
612    Ok(out)
613}
614
615fn encode_varint(mut value: u64, out: &mut Vec<u8>) {
616    while value >= 0x80 {
617        out.push((value as u8 & 0x7F) | 0x80);
618        value >>= 7;
619    }
620    out.push(value as u8);
621}
622
623fn decode_varint(input: &[u8], cur: &mut usize) -> Result<u64, BodyEnvelopeError> {
624    let mut shift = 0u32;
625    let mut value = 0u64;
626
627    for _ in 0..10 {
628        let byte = *take(input, cur, 1)?
629            .first()
630            .ok_or(BodyEnvelopeError::Truncated)?;
631        let chunk = (byte & 0x7F) as u64;
632
633        if shift >= 64 && chunk != 0 {
634            return Err(BodyEnvelopeError::InvalidHeader("varint overflow"));
635        }
636
637        value |= chunk
638            .checked_shl(shift)
639            .ok_or(BodyEnvelopeError::InvalidHeader("varint overflow"))?;
640
641        if byte & 0x80 == 0 {
642            return Ok(value);
643        }
644
645        shift += 7;
646    }
647
648    Err(BodyEnvelopeError::InvalidHeader("varint too long"))
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn body_roundtrip_single_recipient() {
657        let recipient_priv = StaticSecret::random_from_rng(OsRng);
658        let recipient_pub = PublicKey::from(&recipient_priv).to_bytes();
659
660        let plain = b"hello application/foctet body";
661        let envelope = seal_body(plain, recipient_pub, b"kid-1").expect("seal");
662        let out = open_body(&envelope, recipient_priv.to_bytes()).expect("open");
663
664        assert_eq!(out, plain);
665    }
666
667    #[test]
668    fn open_rejects_invalid_magic() {
669        let recipient_priv = StaticSecret::random_from_rng(OsRng);
670        let recipient_pub = PublicKey::from(&recipient_priv).to_bytes();
671
672        let plain = b"hello";
673        let mut envelope = seal_body(plain, recipient_pub, b"kid").expect("seal");
674        envelope[0] ^= 0xFF;
675
676        let err = open_body(&envelope, recipient_priv.to_bytes()).expect_err("must fail");
677        assert_eq!(err, BodyEnvelopeError::InvalidMagic);
678    }
679
680    #[test]
681    fn open_rejects_unsupported_version() {
682        let recipient_priv = StaticSecret::random_from_rng(OsRng);
683        let recipient_pub = PublicKey::from(&recipient_priv).to_bytes();
684
685        let plain = b"hello";
686        let mut envelope = seal_body(plain, recipient_pub, b"kid").expect("seal");
687        envelope[8] = 0xFF;
688
689        let err = open_body(&envelope, recipient_priv.to_bytes()).expect_err("must fail");
690        assert_eq!(err, BodyEnvelopeError::UnsupportedVersion(0xFF));
691    }
692
693    #[test]
694    fn open_rejects_truncated_input() {
695        let recipient_priv = StaticSecret::random_from_rng(OsRng);
696        let recipient_pub = PublicKey::from(&recipient_priv).to_bytes();
697
698        let plain = b"hello";
699        let envelope = seal_body(plain, recipient_pub, b"kid").expect("seal");
700        let truncated = &envelope[..envelope.len() - 1];
701
702        let err = open_body(truncated, recipient_priv.to_bytes()).expect_err("must fail");
703        assert_eq!(err, BodyEnvelopeError::Truncated);
704    }
705
706    #[test]
707    fn open_rejects_oversized_lengths() {
708        let recipient_priv = StaticSecret::random_from_rng(OsRng);
709        let recipient_pub = PublicKey::from(&recipient_priv).to_bytes();
710
711        let plain = b"hello";
712        let envelope = seal_body(plain, recipient_pub, b"kid").expect("seal");
713
714        let limits = BodyEnvelopeLimits {
715            max_header_bytes: 16,
716            ..BodyEnvelopeLimits::default()
717        };
718
719        let err = open_body_with_limits(&envelope, recipient_priv.to_bytes(), &limits)
720            .expect_err("must fail");
721        assert_eq!(err, BodyEnvelopeError::LimitExceeded("header_len"));
722    }
723
724    #[test]
725    fn open_with_wrong_recipient_fails() {
726        let recipient_priv = StaticSecret::random_from_rng(OsRng);
727        let recipient_pub = PublicKey::from(&recipient_priv).to_bytes();
728        let wrong_priv = StaticSecret::random_from_rng(OsRng);
729
730        let plain = b"hello";
731        let envelope = seal_body(plain, recipient_pub, b"kid").expect("seal");
732
733        let err = open_body(&envelope, wrong_priv.to_bytes()).expect_err("must fail");
734        assert_eq!(err, BodyEnvelopeError::RecipientNotFound);
735    }
736
737    #[test]
738    fn malformed_wrapped_key_is_rejected() {
739        let recipient_priv = StaticSecret::random_from_rng(OsRng);
740        let recipient_pub = PublicKey::from(&recipient_priv).to_bytes();
741
742        let plain = b"hello";
743        let envelope = seal_body(plain, recipient_pub, b"kid").expect("seal");
744
745        // Parse once to locate wrapped_key_len varint, then corrupt it from 48 to 47.
746        let mut cur = 0usize;
747        cur += 8 + 1 + 1 + 1 + 1;
748        let _ = decode_varint(&envelope, &mut cur).expect("header_len");
749        let _ = decode_varint(&envelope, &mut cur).expect("recipient_count");
750        let _ = decode_varint(&envelope, &mut cur).expect("payload_len");
751        cur += X25519_PUBLIC_KEY_LEN + XCHACHA_NONCE_LEN;
752        let _ = decode_varint(&envelope, &mut cur).expect("key_id_len");
753
754        let mut malformed = envelope.clone();
755        malformed[cur] = 47; // wrapped_key_len varint (single-byte in this fixture)
756
757        let err = open_body(&malformed, recipient_priv.to_bytes()).expect_err("must fail");
758        assert_eq!(
759            err,
760            BodyEnvelopeError::InvalidHeader("invalid wrapped_key_len for v0")
761        );
762    }
763}