Skip to main content

xmtp_mls_common/mls_ext/
payload_encryption.rs

1use openmls::ciphersuite::hpke::Error as OpenmlsHpkeError;
2use openmls::prelude::tls_codec::Error as TlsCodecError;
3use openmls_traits::crypto::OpenMlsCrypto;
4use openmls_traits::types::HpkeCiphertext;
5use thiserror::Error;
6use tls_codec::{Deserialize, Serialize};
7use xmtp_common::RetryableError;
8use xmtp_id::key_package::WrapperAlgorithm;
9
10static LIBCRUX_CRYPTO_PROVIDER: std::sync::LazyLock<openmls_libcrux_crypto::CryptoProvider> =
11    std::sync::LazyLock::new(|| {
12        openmls_libcrux_crypto::CryptoProvider::new().expect("Failed to create CryptoProvider")
13    });
14
15#[derive(Debug, Error)]
16pub enum WrapPayloadError {
17    #[error("OpenMLS HPKE error: {0}")]
18    Hpke(#[from] OpenmlsHpkeError),
19    #[error("TLS Codec error: {0}")]
20    TlsError(#[from] TlsCodecError),
21    #[error(transparent)]
22    Crypto(#[from] openmls_traits::types::CryptoError),
23}
24
25#[derive(Debug, Error)]
26pub enum UnwrapPayloadError {
27    #[error("OpenMLS HPKE error: {0}")]
28    Hpke(#[from] OpenmlsHpkeError),
29    #[error("TLS Codec error: {0}")]
30    TlsError(#[from] TlsCodecError),
31    #[error(transparent)]
32    Crypto(#[from] openmls_traits::types::CryptoError),
33}
34
35impl RetryableError for WrapPayloadError {
36    fn is_retryable(&self) -> bool {
37        false
38    }
39}
40
41impl RetryableError for UnwrapPayloadError {
42    fn is_retryable(&self) -> bool {
43        false
44    }
45}
46
47/// Wrap a payload (plus optional secondary payload) in an outer layer of HPKE
48/// encryption using the specified [WrapperAlgorithm]. The algorithm and public
49/// key type MUST match.
50///
51/// `label` is fed to the HPKE `EncryptContext` as the domain-separation label.
52/// Use [`xmtp_configuration::WELCOME_HPKE_LABEL`] for welcome-flow compatibility.
53///
54/// For the `XWingMLKEM768Draft6` algorithm, `payload` and `secondary_payload`
55/// are wrapped using the same HPKE public key. The first returned vec is the
56/// `HpkeCiphertext` with TLS serialization. The second vec is just ciphertext.
57pub fn wrap_payload_hpke(
58    payload: &[u8],
59    secondary_payload: &[u8],
60    hpke_public_key: &[u8],
61    wrapper_algorithm: WrapperAlgorithm,
62    label: &str,
63) -> Result<(Vec<u8>, Vec<u8>), WrapPayloadError> {
64    // The following implementation is the same as calling openmls_libcrux_crypto::CryptoProvider::hpke_seal(...)
65    // but uses the context to encrypt multiple messages at once using the same context
66    // because openmls only supports one shot messages.
67
68    let context = openmls::prelude::hpke::EncryptContext::from((label, [].as_slice()));
69    let info = context.tls_serialize_detached()?;
70    let aad = &[];
71
72    let map_hpke_error = |e| match e {
73        hpke_rs::HpkeError::InvalidConfig => openmls::prelude::CryptoError::SenderSetupError,
74        _ => openmls::prelude::CryptoError::HpkeEncryptionError,
75    };
76
77    let pk_r = hpke_rs::HpkePublicKey::new(hpke_public_key.to_vec());
78    let mut config = wrapper_algorithm.to_hpke_config();
79
80    let (enc, mut ctxt) = config
81        .setup_sender(&pk_r, &info, None, None, None)
82        .map_err(map_hpke_error)?;
83
84    let encrypted_payload = ctxt
85        .seal(aad, payload)
86        .map(|ct| HpkeCiphertext {
87            kem_output: enc.into(),
88            ciphertext: ct.into(),
89        })
90        .map_err(map_hpke_error)?;
91    let encrypted_secondary_payload = ctxt.seal(aad, secondary_payload).map_err(map_hpke_error)?;
92
93    Ok((
94        encrypted_payload.tls_serialize_detached()?,
95        encrypted_secondary_payload,
96    ))
97}
98
99/// Unwrap a payload that was wrapped using the specified [WrapperAlgorithm].
100/// The algorithm and private key type MUST match. `label` MUST match the value
101/// used at wrap time.
102pub fn unwrap_payload_hpke(
103    wrapped_payload: &[u8],
104    wrapped_secondary_payload: &[u8],
105    private_key: &[u8],
106    wrapper_algorithm: WrapperAlgorithm,
107    label: &str,
108) -> Result<(Vec<u8>, Vec<u8>), UnwrapPayloadError> {
109    let ciphertext = HpkeCiphertext::tls_deserialize_exact(wrapped_payload)?;
110
111    // The following implementation is the same as calling openmls_libcrux_crypto::CryptoProvider::hpke_open(...)
112    // but uses the context to decrypt multiple messages at once using the same context
113    // because openmls only supports one shot messages.
114
115    let context = openmls::prelude::hpke::EncryptContext::from((label, [].as_slice()));
116    let info = context.tls_serialize_detached()?;
117    let aad = &[];
118
119    let config = wrapper_algorithm.to_hpke_config();
120
121    let sk_r = hpke_rs::HpkePrivateKey::new(private_key.to_vec());
122
123    let map_hpke_error = |_| openmls::ciphersuite::hpke::Error::DecryptionFailed;
124
125    let mut ctxt = config
126        .setup_receiver(
127            ciphertext.kem_output.as_ref(),
128            &sk_r,
129            &info,
130            None,
131            None,
132            None,
133        )
134        .map_err(map_hpke_error)?;
135
136    let payload = ctxt
137        .open(aad, ciphertext.ciphertext.as_ref())
138        .map_err(map_hpke_error)?;
139    let secondary_payload = if wrapped_secondary_payload.is_empty() {
140        vec![]
141    } else {
142        ctxt.open(aad, wrapped_secondary_payload)
143            .map_err(map_hpke_error)?
144    };
145
146    Ok((payload, secondary_payload))
147}
148
149/// Wrap a payload with symmetric AEAD encryption (caller-supplied key + nonce).
150///
151/// Domain separation is handled by construction: callers MUST scope the
152/// symmetric key to a single use-case.
153pub fn wrap_payload_symmetric(
154    data: &[u8],
155    aead_type: openmls::prelude::AeadType,
156    symmetric_key: &[u8],
157    nonce: &[u8],
158) -> Result<Vec<u8>, WrapPayloadError> {
159    (*LIBCRUX_CRYPTO_PROVIDER)
160        .aead_encrypt(aead_type, symmetric_key, data, nonce, &[])
161        .map_err(Into::into)
162}
163
164/// Unwrap a payload that was wrapped with [`wrap_payload_symmetric`].
165pub fn unwrap_payload_symmetric(
166    data: &[u8],
167    aead_type: openmls::prelude::AeadType,
168    symmetric_key: &[u8],
169    nonce: &[u8],
170) -> Result<Vec<u8>, UnwrapPayloadError> {
171    (*LIBCRUX_CRYPTO_PROVIDER)
172        .aead_decrypt(aead_type, symmetric_key, data, nonce, &[])
173        .map_err(Into::into)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use openmls_traits::{crypto::OpenMlsCrypto, random::OpenMlsRand};
180    use xmtp_configuration::{CIPHERSUITE, POST_QUANTUM_CIPHERSUITE, WELCOME_HPKE_LABEL};
181
182    // These public test keys and ciphertexts were made with libcrux-kem 0.0.9.
183    // See payload_encryption/fixtures/legacy_xwing/README.md for their source.
184    const LEGACY_XWING_PUBLIC_KEY: &str =
185        include_str!("payload_encryption/fixtures/legacy_xwing/public_key.hex");
186    const LEGACY_XWING_PRIVATE_KEY: &str =
187        include_str!("payload_encryption/fixtures/legacy_xwing/private_key.hex");
188    const LEGACY_XWING_WRAPPED_PAYLOAD: &str =
189        include_str!("payload_encryption/fixtures/legacy_xwing/wrapped_payload.hex");
190    const LEGACY_XWING_SECONDARY_CIPHERTEXT: &str =
191        include_str!("payload_encryption/fixtures/legacy_xwing/secondary_ciphertext.hex");
192    const LEGACY_XWING_PAYLOAD: &[u8] = b"persisted Welcome compatibility fixture";
193    const LEGACY_XWING_SECONDARY_PAYLOAD: &[u8] = b"persisted Welcome metadata";
194    const LEGACY_XWING_IKM: [u8; 32] = [7; 32];
195
196    #[xmtp_common::test(unwrap_try = true)]
197    fn legacy_xwing_opens_persisted_welcome() {
198        let (payload, secondary) = unwrap_payload_hpke(
199            &hex::decode(LEGACY_XWING_WRAPPED_PAYLOAD.trim())?,
200            &hex::decode(LEGACY_XWING_SECONDARY_CIPHERTEXT.trim())?,
201            &hex::decode(LEGACY_XWING_PRIVATE_KEY.trim())?,
202            WrapperAlgorithm::XWingMLKEM768Draft6,
203            WELCOME_HPKE_LABEL,
204        )?;
205
206        assert_eq!(payload, LEGACY_XWING_PAYLOAD);
207        assert_eq!(secondary, LEGACY_XWING_SECONDARY_PAYLOAD);
208    }
209
210    #[xmtp_common::test(unwrap_try = true)]
211    fn legacy_xwing_key_encoding_is_stable() {
212        let hpke = WrapperAlgorithm::XWingMLKEM768Draft6.to_hpke_config();
213        let pair = hpke.derive_key_pair(&LEGACY_XWING_IKM)?;
214
215        assert_eq!(
216            pair.public_key().as_slice(),
217            hex::decode(LEGACY_XWING_PUBLIC_KEY.trim())?
218        );
219        assert_eq!(
220            pair.private_key().as_slice(),
221            hex::decode(LEGACY_XWING_PRIVATE_KEY.trim())?
222        );
223    }
224
225    #[xmtp_common::test(unwrap_try = true)]
226    fn legacy_xwing_stored_keys_accept_new_welcome() {
227        let (wrapped, metadata) = wrap_payload_hpke(
228            LEGACY_XWING_PAYLOAD,
229            LEGACY_XWING_SECONDARY_PAYLOAD,
230            &hex::decode(LEGACY_XWING_PUBLIC_KEY.trim())?,
231            WrapperAlgorithm::XWingMLKEM768Draft6,
232            WELCOME_HPKE_LABEL,
233        )?;
234        let (payload, secondary) = unwrap_payload_hpke(
235            &wrapped,
236            &metadata,
237            &hex::decode(LEGACY_XWING_PRIVATE_KEY.trim())?,
238            WrapperAlgorithm::XWingMLKEM768Draft6,
239            WELCOME_HPKE_LABEL,
240        )?;
241
242        assert_eq!(payload, LEGACY_XWING_PAYLOAD);
243        assert_eq!(secondary, LEGACY_XWING_SECONDARY_PAYLOAD);
244    }
245
246    const TEST_LABEL: &str = "test xmtp payload";
247
248    fn fresh_curve25519_keypair() -> (Vec<u8>, Vec<u8>) {
249        let crypto = openmls_rust_crypto::RustCrypto::default();
250        let ikm = crypto.random_vec(CIPHERSUITE.hash_length()).unwrap();
251        let kp = crypto
252            .derive_hpke_keypair(CIPHERSUITE.hpke_config(), &ikm)
253            .unwrap();
254        (kp.public, kp.private.to_vec())
255    }
256
257    fn fresh_xwing_keypair() -> (Vec<u8>, Vec<u8>) {
258        let crypto = openmls_libcrux_crypto::CryptoProvider::new().unwrap();
259        let ikm = crypto
260            .random_vec(POST_QUANTUM_CIPHERSUITE.hash_length())
261            .unwrap();
262        let kp = crypto
263            .derive_hpke_keypair(POST_QUANTUM_CIPHERSUITE.hpke_config(), &ikm)
264            .unwrap();
265        (kp.public, kp.private.to_vec())
266    }
267
268    #[xmtp_common::test]
269    fn round_trip_curve25519_hpke() {
270        let (pk, sk) = fresh_curve25519_keypair();
271
272        let payload = xmtp_common::rand_vec::<1000>();
273        let secondary = xmtp_common::rand_vec::<32>();
274
275        let wrapped = wrap_payload_hpke(
276            &payload,
277            &secondary,
278            &pk,
279            WrapperAlgorithm::Curve25519,
280            TEST_LABEL,
281        )
282        .unwrap();
283
284        assert_ne!(payload, wrapped.0);
285        assert_ne!(secondary, wrapped.1);
286
287        let unwrapped = unwrap_payload_hpke(
288            &wrapped.0,
289            &wrapped.1,
290            &sk,
291            WrapperAlgorithm::Curve25519,
292            TEST_LABEL,
293        )
294        .unwrap();
295
296        assert_eq!(unwrapped, (payload, secondary));
297    }
298
299    #[xmtp_common::test]
300    fn round_trip_xwing_hpke() {
301        let (pk, sk) = fresh_xwing_keypair();
302
303        let payload = xmtp_common::rand_vec::<1000>();
304        let secondary = xmtp_common::rand_vec::<32>();
305
306        let wrapped = wrap_payload_hpke(
307            &payload,
308            &secondary,
309            &pk,
310            WrapperAlgorithm::XWingMLKEM768Draft6,
311            TEST_LABEL,
312        )
313        .unwrap();
314
315        assert_ne!(payload, wrapped.0);
316        assert_ne!(secondary, wrapped.1);
317
318        let unwrapped = unwrap_payload_hpke(
319            &wrapped.0,
320            &wrapped.1,
321            &sk,
322            WrapperAlgorithm::XWingMLKEM768Draft6,
323            TEST_LABEL,
324        )
325        .unwrap();
326
327        assert_eq!(unwrapped, (payload.clone(), secondary));
328
329        // Empty secondary payload short-circuits to vec![].
330        let unwrapped = unwrap_payload_hpke(
331            &wrapped.0,
332            &[],
333            &sk,
334            WrapperAlgorithm::XWingMLKEM768Draft6,
335            TEST_LABEL,
336        )
337        .unwrap();
338
339        assert_eq!(unwrapped, (payload, vec![]));
340    }
341
342    #[xmtp_common::test]
343    fn wrong_key_fails_curve25519() {
344        let (pk, _sk) = fresh_curve25519_keypair();
345        let (_pk2, sk2) = fresh_curve25519_keypair();
346
347        let payload = xmtp_common::rand_vec::<128>();
348        let secondary = xmtp_common::rand_vec::<32>();
349
350        let wrapped = wrap_payload_hpke(
351            &payload,
352            &secondary,
353            &pk,
354            WrapperAlgorithm::Curve25519,
355            TEST_LABEL,
356        )
357        .unwrap();
358
359        unwrap_payload_hpke(
360            &wrapped.0,
361            &wrapped.1,
362            &sk2,
363            WrapperAlgorithm::Curve25519,
364            TEST_LABEL,
365        )
366        .unwrap_err();
367    }
368
369    #[xmtp_common::test]
370    fn wrong_label_fails_curve25519() {
371        let (pk, sk) = fresh_curve25519_keypair();
372
373        let payload = xmtp_common::rand_vec::<128>();
374        let secondary = xmtp_common::rand_vec::<32>();
375
376        let wrapped = wrap_payload_hpke(
377            &payload,
378            &secondary,
379            &pk,
380            WrapperAlgorithm::Curve25519,
381            TEST_LABEL,
382        )
383        .unwrap();
384
385        unwrap_payload_hpke(
386            &wrapped.0,
387            &wrapped.1,
388            &sk,
389            WrapperAlgorithm::Curve25519,
390            "different label",
391        )
392        .unwrap_err();
393    }
394
395    #[xmtp_common::test]
396    fn welcome_label_round_trip_matches_xmtp_configuration() {
397        // Sanity-check that the welcome label still round-trips correctly through
398        // the generalized API. This is the configuration the welcome flow uses.
399        let (pk, sk) = fresh_curve25519_keypair();
400
401        let payload = xmtp_common::rand_vec::<256>();
402        let secondary = xmtp_common::rand_vec::<32>();
403
404        let wrapped = wrap_payload_hpke(
405            &payload,
406            &secondary,
407            &pk,
408            WrapperAlgorithm::Curve25519,
409            WELCOME_HPKE_LABEL,
410        )
411        .unwrap();
412
413        let unwrapped = unwrap_payload_hpke(
414            &wrapped.0,
415            &wrapped.1,
416            &sk,
417            WrapperAlgorithm::Curve25519,
418            WELCOME_HPKE_LABEL,
419        )
420        .unwrap();
421
422        assert_eq!(unwrapped, (payload, secondary));
423    }
424
425    #[xmtp_common::test]
426    fn round_trip_symmetric() {
427        let symmetric_key = xmtp_common::rand_array::<32>();
428        let nonce = xmtp_common::rand_array::<12>();
429        let data = xmtp_common::rand_array::<1000>();
430
431        let wrapped = wrap_payload_symmetric(
432            &data,
433            openmls::prelude::AeadType::ChaCha20Poly1305,
434            &symmetric_key,
435            &nonce,
436        )
437        .unwrap();
438        let unwrapped = unwrap_payload_symmetric(
439            &wrapped,
440            openmls::prelude::AeadType::ChaCha20Poly1305,
441            &symmetric_key,
442            &nonce,
443        )
444        .unwrap();
445        assert_eq!(data.as_slice(), unwrapped.as_slice());
446    }
447
448    #[xmtp_common::test]
449    fn symmetric_wrong_key_fails() {
450        let symmetric_key = xmtp_common::rand_array::<32>();
451        let wrong_key = xmtp_common::rand_array::<32>();
452        let nonce = xmtp_common::rand_array::<12>();
453        let data = xmtp_common::rand_array::<1000>();
454
455        let wrapped = wrap_payload_symmetric(
456            &data,
457            openmls::prelude::AeadType::ChaCha20Poly1305,
458            &symmetric_key,
459            &nonce,
460        )
461        .unwrap();
462        unwrap_payload_symmetric(
463            &wrapped,
464            openmls::prelude::AeadType::ChaCha20Poly1305,
465            &wrong_key,
466            &nonce,
467        )
468        .unwrap_err();
469    }
470}