Skip to main content

quantum_shield/
multi.rs

1//! Multi-recipient envelopes (`QSM2`).
2//!
3//! The payload is encrypted once under a random content-encryption key (CEK);
4//! the CEK is then wrapped separately for each recipient using that
5//! recipient's **full hybrid** shared secret (X25519 + ML-KEM-1024, via
6//! [`hybrid_kem::encapsulate`]). This is *not* the v1 OR-flaw: every wrap is
7//! itself a hybrid KEM, and the CEK is uniformly random, so breaking one
8//! recipient's classical or post-quantum key alone reveals nothing.
9//!
10//! ## Binding
11//!
12//! - Each wrap's AEAD binds `header || recipient_count` as associated data,
13//!   so a wrap cannot be lifted into an envelope with a different recipient
14//!   count or format.
15//! - The payload AEAD binds the **entire prefix** (header, count, the CEK
16//!   commitment, *all* wraps, and the payload nonce). Adding, removing,
17//!   reordering, or duplicating any wrap changes the payload tag, so tampering
18//!   fails as a uniform [`Error::DecryptionFailed`].
19//! - A `SHA3-256(CEK)` commitment is carried in the envelope and checked by
20//!   every recipient against the CEK they recovered. AES-GCM is not
21//!   key-committing, so without this a malicious sender could wrap *different*
22//!   CEKs to different recipients and craft one payload that decrypts to
23//!   different plaintexts per recipient; the commitment forecloses that.
24//!
25//! ## Opening
26//!
27//! [`open_multi`] trial-decrypts every wrap with no recipient identifier on
28//! the wire — an envelope reveals nothing about who its recipients are. The
29//! cost is one hybrid decapsulation per wrap; [`MAX_RECIPIENTS`] bounds it,
30//! enforced at both seal and parse time.
31
32use crate::constants::*;
33use crate::error::{Error, Result};
34use crate::hybrid_kem::{self, KemCiphertext};
35use crate::keys::{KeyPair, PublicKeyBundle};
36use crate::wire::{read_header, take, write_header};
37use aes_gcm::aead::{Aead, Payload};
38use aes_gcm::{Aes256Gcm, KeyInit};
39use alloc::boxed::Box;
40use alloc::vec::Vec;
41use sha3::{Digest, Sha3_256};
42use subtle::ConstantTimeEq;
43use zeroize::Zeroizing;
44
45/// Commit to a CEK: `SHA3-256(label || cek)`. Recipients check that the CEK
46/// they recovered matches the single committed value, which stops a malicious
47/// sender from wrapping different CEKs to different recipients (AES-GCM is not
48/// key-committing).
49fn cek_commitment(cek: &[u8; CEK_LEN]) -> [u8; CEK_COMMIT_LEN] {
50    let mut hasher = Sha3_256::new();
51    hasher.update(MULTI_CEK_COMMIT_LABEL);
52    hasher.update(cek);
53    hasher.finalize().into()
54}
55
56/// A CEK wrapped for one recipient.
57#[derive(Clone, Debug, PartialEq, Eq)]
58struct Wrap {
59    epk_x25519: [u8; X25519_PK_LEN],
60    ct_mlkem: Box<[u8; MLKEM1024_CT_LEN]>,
61    wrap_nonce: [u8; NONCE_LEN],
62    wrapped_cek: [u8; CEK_LEN + TAG_LEN],
63}
64
65/// An encrypted message addressed to one or more recipients.
66///
67/// Wire layout (`QSM2`):
68///
69/// ```text
70/// header[6] | recipient_count: u16_be | wrap[0..n] | payload_nonce[12] | payload_ct[..]
71/// wrap = epk_x25519[32] | ct_mlkem[1568] | wrap_nonce[12] | wrapped_cek[48]
72/// ```
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct MultiRecipientEnvelope {
75    /// `SHA3-256(label || CEK)` — every recipient checks their recovered CEK
76    /// against this, preventing sender equivocation.
77    cek_commitment: [u8; CEK_COMMIT_LEN],
78    wraps: Vec<Wrap>,
79    payload_nonce: [u8; NONCE_LEN],
80    payload_ct: Vec<u8>,
81}
82
83impl MultiRecipientEnvelope {
84    /// Number of recipient wraps in this envelope.
85    pub fn recipient_count(&self) -> usize {
86        self.wraps.len()
87    }
88
89    /// The authenticated prefix (header through payload nonce), used as the
90    /// payload AEAD associated data.
91    fn write_prefix(&self, out: &mut Vec<u8>) {
92        debug_assert!(self.wraps.len() <= MAX_RECIPIENTS);
93        write_header(out, MAGIC_MULTI);
94        out.extend_from_slice(&(self.wraps.len() as u16).to_be_bytes());
95        out.extend_from_slice(&self.cek_commitment);
96        for wrap in &self.wraps {
97            out.extend_from_slice(&wrap.epk_x25519);
98            out.extend_from_slice(wrap.ct_mlkem.as_ref());
99            out.extend_from_slice(&wrap.wrap_nonce);
100            out.extend_from_slice(&wrap.wrapped_cek);
101        }
102        out.extend_from_slice(&self.payload_nonce);
103    }
104
105    /// Serialize to the `QSM2` wire format.
106    pub fn to_bytes(&self) -> Vec<u8> {
107        let mut out = Vec::with_capacity(
108            HEADER_LEN
109                + 2
110                + CEK_COMMIT_LEN
111                + self.wraps.len() * WRAP_LEN
112                + NONCE_LEN
113                + self.payload_ct.len(),
114        );
115        self.write_prefix(&mut out);
116        out.extend_from_slice(&self.payload_ct);
117        out
118    }
119
120    /// Parse a `QSM2` envelope.
121    ///
122    /// # Errors
123    ///
124    /// [`Error::InvalidEnvelope`] on malformed input,
125    /// [`Error::TooManyRecipients`] / [`Error::NoRecipients`] on an
126    /// out-of-range count, and version/suite errors for other formats.
127    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
128        let mut rest = read_header(bytes, MAGIC_MULTI, Error::InvalidEnvelope)?;
129
130        let count_bytes: [u8; 2] = take(&mut rest, Error::InvalidEnvelope)?;
131        let count = u16::from_be_bytes(count_bytes) as usize;
132        if count == 0 {
133            return Err(Error::NoRecipients);
134        }
135        if count > MAX_RECIPIENTS {
136            return Err(Error::TooManyRecipients {
137                count,
138                max: MAX_RECIPIENTS,
139            });
140        }
141
142        let cek_commitment = take(&mut rest, Error::InvalidEnvelope)?;
143
144        let mut wraps = Vec::with_capacity(count);
145        for _ in 0..count {
146            let epk_x25519 = take(&mut rest, Error::InvalidEnvelope)?;
147            let ct_mlkem: [u8; MLKEM1024_CT_LEN] = take(&mut rest, Error::InvalidEnvelope)?;
148            let wrap_nonce = take(&mut rest, Error::InvalidEnvelope)?;
149            let wrapped_cek = take(&mut rest, Error::InvalidEnvelope)?;
150            wraps.push(Wrap {
151                epk_x25519,
152                ct_mlkem: Box::new(ct_mlkem),
153                wrap_nonce,
154                wrapped_cek,
155            });
156        }
157
158        let payload_nonce = take(&mut rest, Error::InvalidEnvelope)?;
159        if rest.len() < TAG_LEN {
160            return Err(Error::InvalidEnvelope);
161        }
162        Ok(Self {
163            cek_commitment,
164            wraps,
165            payload_nonce,
166            payload_ct: rest.to_vec(),
167        })
168    }
169}
170
171/// The wrap-AEAD associated data: `header || recipient_count`.
172fn wrap_aad(count: usize) -> Vec<u8> {
173    let mut aad = Vec::with_capacity(HEADER_LEN + 2);
174    write_header(&mut aad, MAGIC_MULTI);
175    aad.extend_from_slice(&(count as u16).to_be_bytes());
176    aad
177}
178
179/// Encrypt `plaintext` for every recipient in `recipients`.
180///
181/// # Errors
182///
183/// [`Error::NoRecipients`] for an empty list, [`Error::TooManyRecipients`]
184/// past [`MAX_RECIPIENTS`], [`Error::MessageTooLarge`] past
185/// [`MAX_PLAINTEXT_LEN`], and [`Error::RandomnessUnavailable`] on RNG failure.
186pub fn seal_multi(
187    plaintext: &[u8],
188    recipients: &[&PublicKeyBundle],
189) -> Result<MultiRecipientEnvelope> {
190    if recipients.is_empty() {
191        return Err(Error::NoRecipients);
192    }
193    if recipients.len() > MAX_RECIPIENTS {
194        return Err(Error::TooManyRecipients {
195            count: recipients.len(),
196            max: MAX_RECIPIENTS,
197        });
198    }
199    if plaintext.len() > MAX_PLAINTEXT_LEN {
200        return Err(Error::MessageTooLarge {
201            len: plaintext.len(),
202            max: MAX_PLAINTEXT_LEN,
203        });
204    }
205
206    let mut cek = Zeroizing::new([0u8; CEK_LEN]);
207    getrandom::fill(cek.as_mut()).map_err(|_| Error::RandomnessUnavailable)?;
208
209    let aad = wrap_aad(recipients.len());
210    let mut wraps = Vec::with_capacity(recipients.len());
211    for recipient in recipients {
212        let (kem_ct, ss) = hybrid_kem::encapsulate(recipient)?;
213        let cipher = Aes256Gcm::new((&*ss).into());
214        let mut wrap_nonce = [0u8; NONCE_LEN];
215        getrandom::fill(&mut wrap_nonce).map_err(|_| Error::RandomnessUnavailable)?;
216        // Encrypting a fixed 32-byte CEK cannot fail (AES-GCM only errors far
217        // past any real length), and its output is exactly CEK_LEN + TAG_LEN.
218        let wrapped = cipher
219            .encrypt(
220                (&wrap_nonce).into(),
221                Payload {
222                    msg: &*cek,
223                    aad: &aad,
224                },
225            )
226            .expect("AES-GCM wrap of a 32-byte CEK is infallible");
227        let wrapped_cek: [u8; CEK_LEN + TAG_LEN] = wrapped
228            .try_into()
229            .expect("AES-256-GCM output is plaintext length + 16-byte tag");
230        wraps.push(Wrap {
231            epk_x25519: kem_ct.epk_x25519,
232            ct_mlkem: kem_ct.ct_mlkem,
233            wrap_nonce,
234            wrapped_cek,
235        });
236    }
237
238    let mut payload_nonce = [0u8; NONCE_LEN];
239    getrandom::fill(&mut payload_nonce).map_err(|_| Error::RandomnessUnavailable)?;
240
241    let mut envelope = MultiRecipientEnvelope {
242        cek_commitment: cek_commitment(&cek),
243        wraps,
244        payload_nonce,
245        payload_ct: Vec::new(),
246    };
247    let mut payload_aad = Vec::new();
248    envelope.write_prefix(&mut payload_aad);
249
250    let cipher = Aes256Gcm::new((&*cek).into());
251    envelope.payload_ct = cipher
252        .encrypt(
253            (&payload_nonce).into(),
254            Payload {
255                msg: plaintext,
256                aad: &payload_aad,
257            },
258        )
259        .map_err(|_| Error::MessageTooLarge {
260            len: plaintext.len(),
261            max: MAX_PLAINTEXT_LEN,
262        })?;
263    Ok(envelope)
264}
265
266/// Decrypt a multi-recipient envelope with `keypair`, if it is a recipient.
267///
268/// Every wrap is trial-decrypted; there is no per-recipient identifier on the
269/// wire. Returns [`Error::DecryptionFailed`] uniformly if `keypair` is not a
270/// recipient or the envelope was tampered with.
271pub fn open_multi(keypair: &KeyPair, envelope: &MultiRecipientEnvelope) -> Result<Vec<u8>> {
272    let aad = wrap_aad(envelope.wraps.len());
273    let mut payload_aad = Vec::new();
274    envelope.write_prefix(&mut payload_aad);
275
276    for wrap in &envelope.wraps {
277        let kem_ct = KemCiphertext {
278            epk_x25519: wrap.epk_x25519,
279            ct_mlkem: wrap.ct_mlkem.clone(),
280        };
281        let ss = hybrid_kem::decapsulate(keypair, &kem_ct);
282        let cipher = Aes256Gcm::new((&*ss).into());
283        let Ok(cek_vec) = cipher.decrypt(
284            (&wrap.wrap_nonce).into(),
285            Payload {
286                msg: &wrap.wrapped_cek,
287                aad: &aad,
288            },
289        ) else {
290            continue;
291        };
292
293        let cek_vec = Zeroizing::new(cek_vec);
294        let cek: Zeroizing<[u8; CEK_LEN]> = Zeroizing::new(
295            cek_vec
296                .as_slice()
297                .try_into()
298                .map_err(|_| Error::DecryptionFailed)?,
299        );
300
301        // Reject a sender who wrapped a different CEK than it committed to
302        // (equivocation). Constant-time compare against the single commitment.
303        let commit_ok: bool = cek_commitment(&cek).ct_eq(&envelope.cek_commitment).into();
304        if !commit_ok {
305            return Err(Error::DecryptionFailed);
306        }
307
308        let payload_cipher = Aes256Gcm::new((&*cek).into());
309        return payload_cipher
310            .decrypt(
311                (&envelope.payload_nonce).into(),
312                Payload {
313                    msg: &envelope.payload_ct,
314                    aad: &payload_aad,
315                },
316            )
317            .map_err(|_| Error::DecryptionFailed);
318    }
319    Err(Error::DecryptionFailed)
320}