Skip to main content

quantum_box/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use hpke::{
4    Deserializable, HpkeError, OpModeR, OpModeS, Serializable,
5    aead::ChaCha20Poly1305,
6    kdf::HkdfSha256,
7    kem::{Kem as KemTrait, XWing},
8    single_shot_open, single_shot_seal_with_rng,
9};
10
11mod keys;
12mod rng;
13pub use keys::{PublicKey, SecretKey};
14
15/// The KEM (Key Encapsulation Mechanism) used: X-Wing is chosen
16/// for its IND-CCA security. Reference: <https://eprint.iacr.org/2024/039.pdf>
17pub(crate) type XKem = XWing;
18/// The Authenticated Encryption (AEAD) algorithm used. `ChaCha20Poly1305` is selected
19/// over AES-GCM because `ChaCha20` is constant-time on any hardware and generally more portable. Also
20/// inspired on libsodium's sealed box choice (`Salsa20-Poly1305`).
21pub(crate) type Aead = ChaCha20Poly1305;
22/// The key derivation function. `HKDF-SHA256` is used because its 128-bit security level
23/// matches the 128-bit (NIST PQC Level 1) target of the X-Wing KEM.
24pub(crate) type Kdf = HkdfSha256;
25
26/// Internal wire format version. Applicable only to this implementation.
27///
28/// Bumped only on a breaking encoding change.
29const VERSION: u8 = 0x01;
30/// The HPKE algorithm ID: X-Wing (ML-KEM-768 and X25519)
31///
32/// The explicit ID (`0x647A`) is assigned in the [IANA HPKE KEM Identifiers Registry](https://www.iana.org/assignments/hpke/hpke.xhtml).
33const KEM_ID: u16 = 0x647A;
34/// The explicit Key Derivation Function: HKDF-SHA256 (RFC 5869)
35///
36/// The explicit ID (`0x0001`) is defined in [RFC 9180](https://www.rfc-editor.org/info/rfc9180/#section-7.2) and IANA registry.
37const KDF_ID: u16 = 0x0001;
38/// The AEAD Cipher: ChaCha20-Poly1305
39///
40/// The explicit ID (`0x0003`) is defined in [RFC 9180](https://www.rfc-editor.org/info/rfc9180/#section-7.3) and IANA registry.
41const AEAD_ID: u16 = 0x0003;
42
43/// The header is attached as a prefix to all sealed ciphertext.
44///
45/// [RFC-9180](https://datatracker.ietf.org/doc/rfc9180/) does not specify a
46/// wire format, so this implementation incorporates it to define encoding. The
47/// header format is inspired by the OHTTP RFC ([RFC 9458](https://www.ietf.org/rfc/rfc9458.html#section-4.3)).
48///
49/// All values are encoded big-endian.
50///
51/// # Security
52/// The header is neither encrypted nor authenticated, it is added only
53/// to future-proof for the cryptographic suite used and encoding format.
54const HEADER: [u8; 7] = {
55    let kem = KEM_ID.to_be_bytes();
56    let kdf = KDF_ID.to_be_bytes();
57    let aead = AEAD_ID.to_be_bytes();
58    [VERSION, kem[0], kem[1], kdf[0], kdf[1], aead[0], aead[1]]
59};
60const HEADER_LEN: usize = HEADER.len();
61/// X-Wing encapsulated key size: ML-KEM-768 ciphertext (1088) + X25519 (32).
62///
63/// See also `XWing::EncappedKey::OutputSize`
64const ENC_LEN: usize = 1120;
65/// Maximum permitted `info` length.
66///
67/// hpke's key schedule panics when `info.len() + psk_id.len() + 5 ≥ 2^16`
68const MAX_INFO_LEN: usize = (1 << 16) - 1 - 5;
69
70impl PublicKey {
71    /// Seal `plaintext` to the `recipient`'s public key. This is analogous to
72    /// libsodium's [Sealed Box](https://libsodium.gitbook.io/doc/public-key_cryptography/sealed_boxes) where
73    /// a message is sent anonymously to a recipient's public key. The important difference is that a hybrid KEM
74    /// is used.
75    ///
76    /// With the choice of X-Wing, the ciphertext remains IND-CCA secure as long as either the security
77    /// properties of `X25519` or `ML-KEM-768` (Kyber768) holds.
78    ///
79    /// # Arguments
80    /// - `recipient`: The public key of the recipient.
81    /// - `plaintext`: The plaintext message to be encrypted.
82    /// - `info`: Optional application-supplied information. This is usually global
83    ///   context (e.g. "a backup of X app"). The exact same `info` must be provided for sealing and unsealing,
84    ///   otherwise unsealing will fail.
85    ///
86    /// # Errors
87    /// - [`Error::Rng`] if the operating system CSPRNG is unavailable.
88    /// - [`Error::InfoExceedsSize`] if `info` is larger than the permitted maximum.
89    /// - [`Error::Seal`] if HPKE encapsulation or AEAD sealing unexpectedly fails.
90    ///
91    /// # Panics
92    /// An unavailable OS CSPRNG is reported as [`Error::Rng`] via an internal
93    /// readiness check. A panic is only reachable in the residual window where
94    /// the CSPRNG passes that check and then fails mid-encapsulation.
95    ///
96    /// # Wire Format
97    /// ```plaintext
98    /// HEADER || ENCAPSULATED_KEY || CIPHERTEXT
99    /// ```
100    pub fn seal(
101        recipient: &PublicKey,
102        plaintext: &[u8],
103        info: Option<&[u8]>,
104    ) -> Result<Vec<u8>, Error> {
105        if info.unwrap_or_default().len() > MAX_INFO_LEN {
106            return Err(Error::InfoExceedsSize);
107        }
108
109        let (enc, ciphertext) = single_shot_seal_with_rng::<Aead, Kdf, XKem>(
110            &OpModeS::Base,
111            recipient.as_hpke(),
112            info.unwrap_or_default(),
113            plaintext,
114            // Making the explicit opinionated decision of not exposing associated data (AAD)
115            // (RFC 5116 authenticated buit not encrypted data) in this reference implementation because
116            // the use cases it intends to cover warrant a global context (i.e. `info`).
117            &[],
118            &mut rng::os_csprng()?,
119        )?;
120        let enc = enc.to_bytes();
121        let mut out = Vec::with_capacity(HEADER_LEN + enc.len() + ciphertext.len());
122        out.extend_from_slice(&HEADER);
123        out.extend_from_slice(enc.as_slice());
124        out.extend_from_slice(&ciphertext);
125        Ok(out)
126    }
127}
128
129impl SecretKey {
130    /// Unseal `ciphertext` with the `recipient`'s secret key.
131    ///
132    /// # Returns
133    /// The opened plaintext.
134    ///
135    /// # Errors
136    /// - [`Error::EmptyCiphertext`] if the ciphertext is empty.
137    /// - [`Error::Decode`] if the ciphertext is invalid
138    /// - [`Error::UnsupportedVersion`] if the header specifies an unsupported version.
139    /// - [`Error::UnsupportedSuite`] if the header specifices an unsupported cryptographic suite.
140    /// - [`Error::Unseal`] if the ciphertext cannot be unsealed.
141    pub fn unseal(
142        recipient: &SecretKey,
143        ciphertext: &[u8],
144        info: Option<&[u8]>,
145    ) -> Result<Vec<u8>, Error> {
146        if info.unwrap_or_default().len() > MAX_INFO_LEN {
147            return Err(Error::InfoExceedsSize);
148        }
149
150        if ciphertext.is_empty() {
151            return Err(Error::EmptyCiphertext);
152        }
153
154        let Some((&[version, kem0, kem1, kdf0, kdf1, aead0, adead1], ciphertext)) =
155            ciphertext.split_first_chunk::<HEADER_LEN>()
156        else {
157            return Err(Error::Decode);
158        };
159
160        if version != VERSION {
161            return Err(Error::UnsupportedVersion(version));
162        }
163
164        let (kem_id, kdf_id, aead_id) = (
165            u16::from_be_bytes([kem0, kem1]),
166            u16::from_be_bytes([kdf0, kdf1]),
167            u16::from_be_bytes([aead0, adead1]),
168        );
169
170        if (kem_id, kdf_id, aead_id) != (KEM_ID, KDF_ID, AEAD_ID) {
171            return Err(Error::UnsupportedSuite);
172        }
173
174        let Some((enc_bytes, ciphertext)) = ciphertext.split_first_chunk::<ENC_LEN>() else {
175            return Err(Error::Decode);
176        };
177
178        let enc = <XKem as KemTrait>::EncappedKey::from_bytes(enc_bytes)?;
179
180        let plaintext = single_shot_open::<Aead, Kdf, XKem>(
181            &OpModeR::Base,
182            recipient.as_hpke(),
183            &enc,
184            info.unwrap_or_default(),
185            ciphertext,
186            // Explicitly empty associated data
187            &[],
188        )?;
189
190        Ok(plaintext)
191    }
192}
193
194/// Failure modes with encryption or keys
195#[derive(Debug, PartialEq, Eq, thiserror::Error)]
196#[non_exhaustive]
197pub enum Error {
198    /// Provided key is malformed: bad encoding, checksum, HRP, or length.
199    #[error("provided key is malformed")]
200    KeyFormat,
201    /// The sealed message is too short or malformed.
202    #[error("sealed message is malformed")]
203    Decode,
204    /// KEM decapsulation failed.
205    #[error("decapsulation failed")]
206    Decap,
207    /// AEAD authentication failed: tampered ciphertext, wrong recipient, wrong `info`.
208    #[error("unable to unseal: AEAD authentication failed")]
209    Unseal,
210    /// Sealing the message failed.
211    #[error("seal unexpectedly failed")]
212    Seal,
213    /// The operating system CSPRNG is unavailable, so no secure randomness
214    /// could be drawn.
215    #[error("operating system CSPRNG is unavailable")]
216    Rng,
217    /// An HPKE state that this construction never produces. Critical library bug.
218    #[error("internal critical bug")]
219    Internal,
220    /// The provided ciphertext contains an invalid or unsupported version.
221    #[error("unsupported version: {0}")]
222    UnsupportedVersion(u8),
223    /// The provided ciphertext specifies an unsupported cryptographic suite.
224    #[error("unsupported suite")]
225    UnsupportedSuite,
226    /// The provided `info` exceeds the max size
227    #[error("info exceeds max size")]
228    InfoExceedsSize,
229    /// The provided ciphertext is empty
230    #[error("empty ciphertext")]
231    EmptyCiphertext,
232}
233
234impl From<HpkeError> for Error {
235    fn from(e: HpkeError) -> Self {
236        match e {
237            HpkeError::OpenError => Error::Unseal,
238            HpkeError::DecapError => Error::Decap,
239            HpkeError::EncapError | HpkeError::SealError => Error::Seal,
240            HpkeError::ValidationError | HpkeError::IncorrectInputLength(_, _) => Error::Decode,
241            HpkeError::MessageLimitReached
242            | HpkeError::KdfOutputTooLong
243            | HpkeError::InvalidPskBundle => Error::Internal,
244        }
245    }
246}
247
248#[expect(clippy::unwrap_used, reason = "clearer in tests")]
249#[cfg(test)]
250mod tests {
251    use super::{ENC_LEN, Error, HEADER, HEADER_LEN, MAX_INFO_LEN, PublicKey, SecretKey, VERSION};
252    use std::collections::HashSet;
253
254    /// `Poly-1305` authentication tag length
255    ///
256    /// Reference: <https://en.wikipedia.org/wiki/Poly1305>
257    const TAG_LEN: usize = 16;
258
259    fn keypair(seed: &[u8; 32]) -> (SecretKey, PublicKey) {
260        let sk = SecretKey::from_seed(seed);
261        let pk = sk.public_key();
262        (sk, pk)
263    }
264
265    #[test]
266    fn seal_unseal_roundtrip() {
267        let (sk, pk) = keypair(&[7u8; 32]);
268        let msg: &[u8] = b"execute order 66";
269
270        let sealed = PublicKey::seal(&pk, msg, None).unwrap();
271
272        let unsealed = SecretKey::unseal(&sk, &sealed, None).unwrap();
273
274        assert_eq!(unsealed, msg);
275    }
276
277    #[test]
278    fn roundtrips_empty_plaintext() {
279        let (sk, pk) = keypair(&[0u8; 32]);
280
281        let sealed = PublicKey::seal(&pk, &[], None).unwrap();
282        assert!(!sealed.is_empty());
283
284        let unsealed = SecretKey::unseal(&sk, &sealed, None).unwrap();
285
286        assert!(unsealed.is_empty());
287    }
288
289    #[test]
290    fn roundtrips_with_matching_info() {
291        let (sk, pk) = keypair(&[3u8; 32]);
292        let msg: &[u8] = b"never tell me the odds";
293        let info: &[u8] = b"com.example";
294
295        let sealed = PublicKey::seal(&pk, msg, Some(info)).unwrap();
296
297        let unsealed = SecretKey::unseal(&sk, &sealed, Some(info)).unwrap();
298
299        assert_eq!(unsealed, msg);
300    }
301
302    #[test]
303    fn unseal_fails_with_mismatched_info() {
304        let (sk, pk) = keypair(&[4u8; 32]);
305
306        let sealed = PublicKey::seal(&pk, b"it's a trap", Some(b"context-a")).unwrap();
307
308        assert_eq!(
309            SecretKey::unseal(&sk, &sealed, Some(b"context-b")),
310            Err(Error::Unseal)
311        );
312    }
313
314    #[test]
315    fn unseal_fails_with_wrong_recipient() {
316        let (_sk, pk) = keypair(&[1u8; 32]);
317        let (other_sk, _other_pk) = keypair(&[2u8; 32]);
318
319        let sealed = PublicKey::seal(&pk, b"for my eyes only", None).unwrap();
320
321        assert_eq!(
322            SecretKey::unseal(&other_sk, &sealed, None),
323            Err(Error::Unseal)
324        );
325    }
326
327    #[test]
328    fn unseal_fails_on_tampered_ciphertext() {
329        let (sk, pk) = keypair(&[9u8; 32]);
330
331        let sealed = PublicKey::seal(&pk, b"execute order 66", None).unwrap();
332
333        let mut tampered = sealed.clone();
334        let last = tampered.len() - 1;
335        tampered[last] ^= 0x01;
336
337        assert_eq!(SecretKey::unseal(&sk, &tampered, None), Err(Error::Unseal));
338    }
339
340    #[test]
341    fn unseal_rejects_empty_ciphertext() {
342        let (sk, _pk) = keypair(&[1u8; 32]);
343
344        assert_eq!(
345            SecretKey::unseal(&sk, b"", None),
346            Err(Error::EmptyCiphertext)
347        );
348    }
349
350    #[test]
351    fn unseal_rejects_ciphertext_shorter_than_header() {
352        let (sk, _pk) = keypair(&[1u8; 32]);
353
354        assert_eq!(SecretKey::unseal(&sk, b"short", None), Err(Error::Decode));
355    }
356
357    #[test]
358    fn unseal_rejects_truncated_encapsulated_key() {
359        let (sk, pk) = keypair(&[6u8; 32]);
360
361        let sealed = PublicKey::seal(&pk, b"this message will self-destruct", None).unwrap();
362
363        // Valid header, but the encapsulated key is cut short.
364        let truncated = &sealed[..HEADER_LEN + 10];
365
366        assert_eq!(SecretKey::unseal(&sk, truncated, None), Err(Error::Decode));
367    }
368
369    #[test]
370    fn unseal_rejects_unsupported_version() {
371        let (sk, pk) = keypair(&[1u8; 32]);
372
373        let sealed = PublicKey::seal(&pk, b"hello there", None).unwrap();
374        let mut bad = sealed.clone();
375        let bad_version = VERSION.wrapping_add(1);
376        bad[0] = bad_version;
377
378        assert_eq!(
379            SecretKey::unseal(&sk, &bad, None),
380            Err(Error::UnsupportedVersion(bad_version))
381        );
382    }
383
384    #[test]
385    fn unseal_rejects_unsupported_suite() {
386        let (sk, pk) = keypair(&[1u8; 32]);
387
388        let sealed = PublicKey::seal(&pk, b"hello there", None).unwrap();
389        let mut bad = sealed.clone();
390        bad[1] ^= 0xFF; // Corrupt a KEM ID byte
391
392        assert_eq!(
393            SecretKey::unseal(&sk, &bad, None),
394            Err(Error::UnsupportedSuite)
395        );
396    }
397
398    #[test]
399    fn seal_prepends_wire_header() {
400        let (_sk, pk) = keypair(&[1u8; 32]);
401
402        let sealed = PublicKey::seal(&pk, b"hello there", None).unwrap();
403
404        assert_eq!(&sealed[..HEADER_LEN], &HEADER);
405    }
406
407    #[test]
408    fn seal_output_has_expected_length() {
409        let (_sk, pk) = keypair(&[1u8; 32]);
410        let msg: &[u8] = b"hello there";
411
412        let sealed = PublicKey::seal(&pk, msg, None).unwrap();
413
414        // HEADER || ENCAPSULATED_KEY || len(plaintext + authentication tag)
415        assert_eq!(sealed.len(), HEADER_LEN + ENC_LEN + msg.len() + TAG_LEN);
416    }
417
418    #[test]
419    fn seal_is_non_deterministic() {
420        let (_sk, pk) = keypair(&[1u8; 32]);
421        let msg: &[u8] = b"same message";
422
423        let sealed = PublicKey::seal(&pk, msg, None).unwrap();
424        let sealed2 = PublicKey::seal(&pk, msg, None).unwrap();
425
426        assert_ne!(sealed, sealed2);
427    }
428
429    /// Regression guard for encapsulation-randomness reuse: sealing the same
430    /// plaintext to the same recipient with the same `info` must draw fresh
431    /// X-Wing encapsulation randomness every call. A repeated encapsulated key
432    /// would mean a repeated AEAD key and base nonce (nonce reuse).
433    #[test]
434    fn seal_draws_fresh_encapsulation_randomness_each_call() {
435        let (_sk, pk) = keypair(&[1u8; 32]);
436        let msg: &[u8] = b"same message, same recipient, same info";
437        let info: &[u8] = b"com.example";
438
439        let mut enc_keys = HashSet::new();
440        for _ in 0..64 {
441            let sealed = PublicKey::seal(&pk, msg, Some(info)).unwrap();
442            let enc = sealed[HEADER_LEN..HEADER_LEN + ENC_LEN].to_vec();
443            assert!(
444                enc_keys.insert(enc),
445                "encapsulated key repeated: encapsulation randomness was reused"
446            );
447        }
448        assert_eq!(enc_keys.len(), 64);
449    }
450
451    #[test]
452    fn seal_and_unseal_accept_info_at_max_len() {
453        let (sk, pk) = keypair(&[1u8; 32]);
454        let msg: &[u8] = b"boundary";
455        let info = vec![0x2a; 2_usize.pow(16) - 5 - 1];
456
457        let sealed = PublicKey::seal(&pk, msg, Some(&info)).unwrap();
458        let unsealed = SecretKey::unseal(&sk, &sealed, Some(&info)).unwrap();
459
460        assert_eq!(unsealed, msg);
461    }
462
463    #[test]
464    fn seal_rejects_info_over_max_len() {
465        let (_sk, pk) = keypair(&[1u8; 32]);
466        let info = vec![0x2a; MAX_INFO_LEN + 1];
467
468        assert_eq!(
469            PublicKey::seal(&pk, b"nope", Some(&info)),
470            Err(Error::InfoExceedsSize)
471        );
472    }
473
474    #[test]
475    fn unseal_rejects_info_over_max_len() {
476        let (sk, pk) = keypair(&[1u8; 32]);
477        let sealed = PublicKey::seal(&pk, b"nope", None).unwrap();
478        let info = vec![0x2a; MAX_INFO_LEN + 1];
479
480        assert_eq!(
481            SecretKey::unseal(&sk, &sealed, Some(&info)),
482            Err(Error::InfoExceedsSize)
483        );
484    }
485}