reach_encryption/
public_key.rs

1// SPDX-FileCopyrightText: 2023—2025 eaon <eaon@posteo.net>
2// SPDX-License-Identifier: EUPL-1.2
3
4//! Public key encryption using the X-Wing hybrid KEM, along with SIGMA-I-style
5//! authenticated encryption.
6
7use std::ops::Deref;
8
9use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce, aead::Aead};
10use ecdh_omr::{Blind, TakeTheHint};
11use hmac::Mac;
12use hmac::digest::{MacError, core_api::CoreWrapper};
13use ml_kem::EncodedSizeUser;
14use ml_kem::kem::{Decapsulate, Encapsulate};
15use rand_core::CryptoRngCore;
16use reach_signatures::{Sign, VerifyingKeys};
17use sha3::{Digest, Sha3_256, Sha3_512, Sha3_512Core};
18use zeroize::Zeroizing;
19
20use reach_aliases::{
21    BlindedPublicKey, Ed25519Signature, EnvelopeIdHint, EnvelopeIdHints, FnDsaSignature,
22    MlKemCiphertext, MlKemPublic, MlKemSecret, X25519Public, X25519Secret, XChaChaKey,
23};
24use reach_core::{
25    ParticipantPublicKeys, ProstDecode, ProstEncode, error,
26    memory::{Credentials, HintedEnvelopeId, Key, MessageVaultPassport},
27    wire::{CredentialVault, EnvelopeSeed, Salts, SealedEnvelopeId, SealedMessageVaultId},
28};
29use reach_signatures::{Digestible, Signatures, Verifier, verify_digest};
30
31use crate::{Ciphertext, Decryptable, Encryptable, Nonce, aliases::*, decrypt};
32
33/// Provides access to both elliptic curve and post-quantum secret keys.
34///
35/// Abstracts over different participant types that hold the secret keys needed
36/// for X-Wing hybrid KEM decryption operations.
37pub trait ParticipantSecretKeys {
38    /// Access to a type's X25519 elliptic curve secret key.
39    fn ec_secret_key(&self) -> &X25519Secret;
40    /// Access to a type's ML-KEM post-quantum secret key.
41    fn pq_secret_key(&self) -> &MlKemSecret;
42}
43
44/// Encrypted with hybrid public key cryptography.
45///
46/// Provides access to both the ephemeral elliptic curve public key and the
47/// post-quantum KEM ciphertext that together form the hybrid encryption.
48pub trait PublicKeyEncrypted {
49    /// Access to the ephemeral X25519 public key used for encryption.
50    fn ec_public_key(&self) -> &X25519Public;
51    /// Access to the ML-KEM ciphertext containing the encapsulated key.
52    fn pq_ciphertext(&self) -> &MlKemCiphertext;
53}
54
55impl<T> Nonce for T
56where
57    T: PublicKeyEncrypted,
58{
59    fn nonce(&self) -> &XNonce {
60        XNonce::from_slice(&self.ec_public_key().as_bytes()[..24])
61    }
62}
63
64impl<T> Nonce for Vec<T>
65where
66    T: Ciphertext,
67{
68    fn nonce(&self) -> &XNonce {
69        XNonce::from_slice(
70            &self
71                .last()
72                .expect("Can't derive a nonce from an empty Vec")
73                .ciphertext()[..24],
74        )
75    }
76}
77
78/// Constructed type from public key encryption components.
79///
80/// Allows encrypted data structures to be built from their parts: the ephemeral
81/// public key, an ephemeral KEM ciphertext, and the actual encrypted data.
82pub trait PublicKeyEncryptedFromParts {
83    /// Construct an encrypted data structure from its components.
84    fn from_parts(
85        ec_public_key: X25519Public,
86        pq_ciphertext: MlKemCiphertext,
87        public_key_ciphertext: Vec<u8>,
88    ) -> Self;
89}
90
91macro_rules! public_key_encrypted {
92    ($type_name:ident, $ciphertext:ident) => {
93        public_key_encrypted!($type_name, ec_public_key, pq_ciphertext, $ciphertext);
94    };
95    (
96        $type_name:ident,
97        $ec_public_key:ident,
98        $pq_ciphertext:ident,
99        $ciphertext:ident
100    ) => {
101        impl PublicKeyEncrypted for $type_name {
102            fn ec_public_key(&self) -> &X25519Public {
103                &self.$ec_public_key
104            }
105
106            fn pq_ciphertext(&self) -> &MlKemCiphertext {
107                &self.$pq_ciphertext
108            }
109        }
110
111        impl Ciphertext for $type_name {
112            fn ciphertext(&self) -> &[u8] {
113                self.$ciphertext.as_slice()
114            }
115        }
116
117        impl PublicKeyEncryptedFromParts for $type_name {
118            fn from_parts(
119                $ec_public_key: X25519Public,
120                $pq_ciphertext: MlKemCiphertext,
121                $ciphertext: Vec<u8>,
122            ) -> Self {
123                Self {
124                    $ec_public_key,
125                    $pq_ciphertext,
126                    $ciphertext,
127                }
128            }
129        }
130    };
131}
132
133public_key_encrypted!(CredentialVault, credentials_ciphertext);
134public_key_encrypted!(SealedEnvelopeId, envelope_id_ciphertext);
135public_key_encrypted!(SealedMessageVaultId, message_vault_id_ciphertext);
136
137/// Construct type from a wrapped value and authentication components.
138///
139/// Allows authenticated wrapper types to be built from their constituent parts:
140/// the wrapped data, dual signatures, and MAC for verifying key authenticity.
141pub trait AuthenticatingWrapperFromParts<W>
142where
143    W: Sized,
144{
145    /// Construct an authenticated wrapper from its components.
146    fn from_parts(
147        inner: W,
148        ec_signature: Ed25519Signature,
149        pq_signature: FnDsaSignature,
150        verifying_keys_mac: Vec<u8>,
151    ) -> Self;
152}
153
154impl AuthenticatingWrapperFromParts<Key> for Credentials {
155    fn from_parts(
156        key: Key,
157        ec_signature: Ed25519Signature,
158        pq_signature: FnDsaSignature,
159        verifying_keys_mac: Vec<u8>,
160    ) -> Self {
161        Self {
162            key,
163            ec_signature,
164            pq_signature,
165            verifying_keys_mac,
166        }
167    }
168}
169
170/// Blind a participant's public key.
171///
172/// Blinding allows a third party to send encrypted hints without knowing the
173/// specific identity of the recipient, enabling anonymous message retrieval.
174pub fn blind_public_key<P>(participant: &P, csprng: &mut impl CryptoRngCore) -> BlindedPublicKey
175where
176    P: ParticipantPublicKeys,
177{
178    participant.ec_public_key().blind(csprng)
179}
180
181fn mac_shared_secret(
182    pq_shared_secret: &MlKemSharedSecret,
183    ec_shared_secret: &X25519SharedSecret,
184    ec_ciphertext: &X25519Public,
185    ec_public_key: &X25519Public,
186    salt: &[u8],
187) -> Zeroizing<[u8; 32]> {
188    let xwing_shared_secret = xwing_shared_secret(
189        pq_shared_secret,
190        ec_shared_secret,
191        ec_ciphertext,
192        ec_public_key,
193    );
194
195    salt_hash_digest(xwing_shared_secret.as_slice(), salt)
196}
197
198/// Generate cipher and cryptographic material for public key encryption.
199///
200/// Creates all the components needed for X-Wing hybrid KEM encryption:
201/// ephemeral keys, shared secrets, and the resulting cipher instance.
202pub fn cipher_and_material_for(
203    ec_public_key: &X25519Public,
204    pq_public_key: &MlKemPublic,
205    salt: &[u8],
206    mac_salt: Option<&[u8]>,
207    csprng: &mut impl CryptoRngCore,
208) -> (
209    XChaCha20Poly1305,
210    Option<Zeroizing<[u8; 32]>>,
211    XNonce,
212    X25519Public,
213    MlKemCiphertext,
214) {
215    let ephemeral_ec_secret_key = X25519Ephemeral::random_from_rng(&mut *csprng);
216    let ephemeral_ec_public_key = X25519Public::from(&ephemeral_ec_secret_key);
217    let ec_shared_secret = ephemeral_ec_secret_key.diffie_hellman(ec_public_key);
218    let (pq_ciphertext, pq_shared_secret) = pq_public_key
219        .encapsulate(csprng)
220        .expect("Encapsulating ML-KEM keys is infallible");
221
222    let mac_shared_secret = mac_salt.map(|mac_salt| {
223        mac_shared_secret(
224            &pq_shared_secret,
225            &ec_shared_secret,
226            &ephemeral_ec_public_key,
227            ec_public_key,
228            mac_salt,
229        )
230    });
231
232    (
233        cipher_for(
234            &pq_shared_secret,
235            &ec_shared_secret,
236            &ephemeral_ec_public_key,
237            ec_public_key,
238            salt,
239        ),
240        mac_shared_secret,
241        XNonce::clone_from_slice(&ephemeral_ec_public_key.as_bytes()[..24]),
242        ephemeral_ec_public_key,
243        pq_ciphertext,
244    )
245}
246
247/// Create a cipher instance using secret keys and encrypted material.
248///
249/// Used during decryption to recreate the same cipher that was used for
250/// encryption by deriving the shared secret from the recipient's secret keys
251/// and the sender's ephemeral material.
252pub fn cipher_with_secrets(
253    ec_secret_key: &X25519Secret,
254    ec_public_key: &X25519Public,
255    pq_secret_key: &MlKemSecret,
256    pq_ciphertext: &MlKemCiphertext,
257    salt: &[u8],
258) -> XChaCha20Poly1305 {
259    let ec_shared_secret = ec_secret_key.diffie_hellman(ec_public_key);
260    let pq_shared_secret = pq_secret_key
261        .decapsulate(pq_ciphertext)
262        .expect("Decapsulation for ML-KEM is infallible");
263
264    cipher_for(
265        &pq_shared_secret,
266        &ec_shared_secret,
267        ec_public_key,
268        &X25519Public::from(ec_secret_key),
269        salt,
270    )
271}
272
273pub(crate) fn cipher_for(
274    pq_shared_secret: &MlKemSharedSecret,
275    ec_shared_secret: &X25519SharedSecret,
276    ec_ciphertext: &X25519Public,
277    ec_public_key: &X25519Public,
278    salt: &[u8],
279) -> XChaCha20Poly1305 {
280    let shared_secret = salt_hash_digest(
281        xwing_shared_secret(
282            pq_shared_secret,
283            ec_shared_secret,
284            ec_ciphertext,
285            ec_public_key,
286        )
287        .as_ref(),
288        salt,
289    );
290    let key = XChaChaKey::from_slice(shared_secret.as_ref());
291
292    XChaCha20Poly1305::new(key)
293}
294
295pub(crate) fn xwing_shared_secret(
296    pq_shared_secret: &MlKemSharedSecret,
297    ec_shared_secret: &X25519SharedSecret,
298    ec_ciphertext: &X25519Public,
299    ec_public_key: &X25519Public,
300) -> Zeroizing<[u8; 32]> {
301    let mut hasher = <Sha3_256 as Digest>::new();
302    hasher.update(br"\.//^\");
303    hasher.update(pq_shared_secret.as_slice());
304    hasher.update(ec_shared_secret.as_bytes());
305    hasher.update(ec_ciphertext.as_bytes());
306    hasher.update(ec_public_key.as_bytes());
307    Zeroizing::new(hasher.finalize().into())
308}
309
310pub(crate) fn salt_hash_digest(hash_digest: &[u8], salt: &[u8]) -> Zeroizing<[u8; 32]> {
311    let mut hasher = <Sha3_256 as Digest>::new();
312    hasher.update(hash_digest);
313    hasher.update(salt);
314    Zeroizing::new(hasher.finalize().into())
315}
316
317/// Decrypts public key encrypted data.
318///
319/// Provides the decryption interface for participants who hold the secret keys
320/// needed to decrypt public key encrypted data structures.
321pub trait PublicKeyDecrypter<A>
322where
323    A: PublicKeyEncrypted + Ciphertext,
324    Self: ParticipantSecretKeys,
325{
326    /// Decrypt hybrid encrypted data using participant's secret keys.
327    ///
328    /// Returns the decrypted and decoded data structure.
329    fn decrypt<D>(&self, encrypted: &A, salts: &Salts) -> Result<D, error::CryptError>
330    where
331        D: ProstDecode + Decryptable<A>,
332    {
333        let cipher = cipher_with_secrets(
334            self.ec_secret_key(),
335            encrypted.ec_public_key(),
336            self.pq_secret_key(),
337            encrypted.pq_ciphertext(),
338            &salts.shared_secret,
339        );
340
341        decrypt(&cipher, encrypted.nonce(), encrypted.ciphertext())
342    }
343
344    /// Generate the MAC shared secret for verifying key authenticity.
345    ///
346    /// This secret is used to verify that the sender's verifying keys are authentic
347    /// and haven't been tampered with during transmission.
348    fn mac_shared_secret(&self, encrypted: &A, salts: &Salts) -> Zeroizing<[u8; 32]> {
349        let ec_shared_secret = &self
350            .ec_secret_key()
351            .diffie_hellman(encrypted.ec_public_key());
352        let pq_shared_secret = &self
353            .pq_secret_key()
354            .decapsulate(encrypted.pq_ciphertext())
355            .expect("Decapsulation for ML-KEM is infallible");
356
357        mac_shared_secret(
358            pq_shared_secret,
359            ec_shared_secret,
360            encrypted.ec_public_key(),
361            &X25519Public::from(self.ec_secret_key()),
362            &salts.verifying_keys_mac,
363        )
364    }
365}
366
367/// Generates and verifies HMAC tags for key authentication.
368pub trait VerifiableMac {
369    /// Generate an HMAC instance using the provided shared secret.
370    fn mac(&self, mac_shared_secret: &Zeroizing<[u8; 32]>) -> impl Mac;
371
372    /// Verify an HMAC tag against the expected value.
373    fn verify_mac(
374        &self,
375        mac_shared_secret: &Zeroizing<[u8; 32]>,
376        tag: &[u8],
377    ) -> Result<(), MacError>;
378
379    /// Generate a finalized HMAC tag as a byte vector.
380    fn finalized_mac(&self, mac_shared_secret: &Zeroizing<[u8; 32]>) -> Vec<u8> {
381        self.mac(mac_shared_secret).finalize().into_bytes().to_vec()
382    }
383}
384
385impl<T> VerifiableMac for T
386where
387    T: Verifier,
388{
389    fn mac(&self, mac_shared_secret: &Zeroizing<[u8; 32]>) -> impl Mac {
390        let mut mac = <HmacSha3_256 as Mac>::new_from_slice(mac_shared_secret.as_slice())
391            .expect("Hmac can take keys of any size.");
392        mac.update(self.ec_verifying_key().as_bytes());
393        mac.update(self.pq_verifying_key());
394
395        mac
396    }
397
398    fn verify_mac(
399        &self,
400        mac_shared_secret: &Zeroizing<[u8; 32]>,
401        tag: &[u8],
402    ) -> Result<(), MacError> {
403        self.mac(mac_shared_secret).verify_slice(tag)
404    }
405}
406
407/// Cryptographic bind ephemeral material and participant keys.
408///
409/// Generates a SIGMA-I-style binding digest for authenticated encryption to
410/// prove that the material involved belong to the same cryptographic operation:
411///
412/// - Ephemeral cryptographic material (EC public key + PQ ciphertext)
413/// - The data being authenticated
414/// - The participant public keys
415///
416/// This prevents substitution attacks where valid components are mixed and
417/// matched in unauthorized ways.
418pub fn binding_digest<P>(
419    ephemeral_ec_public_key: &X25519Public,
420    pq_ciphertext: &MlKemCiphertext,
421    hashable_bytes: Vec<Box<dyn AsRef<[u8]> + '_>>,
422    participant: &P,
423) -> CoreWrapper<Sha3_512Core>
424where
425    P: ParticipantPublicKeys,
426{
427    let mut hasher = <Sha3_512 as Digest>::new();
428    hasher.update(ephemeral_ec_public_key.as_bytes());
429    hasher.update(pq_ciphertext);
430    for bytes in hashable_bytes {
431        hasher.update(bytes.deref());
432    }
433    hasher.update(participant.ec_public_key().as_bytes());
434    hasher.update(participant.pq_public_key().as_bytes());
435
436    hasher
437}
438
439/// Encryptable using SIGMA-I-style authenticated public key encryption.
440///
441/// Provides authenticated encryption that combines X-Wing hybrid KEM encryption
442/// with digital signatures, ensuring both confidentiality and authenticity.
443pub trait PublicKeyEncryptable<W> {
444    /// Encrypt this data using SIGMA-I-style authenticated encryption.
445    ///
446    /// Provides confidentiality (via X-Wing) and authenticity (via digital
447    /// signatures and MAC verification) with cryptographic binding to prevent
448    /// component substitution attacks.
449    fn authenticated_encrypt<S, V, R, A>(
450        self,
451        signing_keys: &S,
452        verifier: &V,
453        recipient: &R,
454        salts: &Salts,
455        csprng: &mut impl CryptoRngCore,
456    ) -> Result<A, error::CryptError>
457    where
458        S: Sign + VerifyingKeys<V>,
459        V: Verifier,
460        A: PublicKeyEncryptedFromParts,
461        R: ParticipantPublicKeys,
462        W: AuthenticatingWrapperFromParts<Self> + Decryptable<A> + ProstEncode,
463        Self: Digestible + Sized,
464    {
465        let (cipher, mac_shared_secret, nonce, ec_public_key, pq_ciphertext) =
466            cipher_and_material_for(
467                recipient.ec_public_key(),
468                recipient.pq_public_key(),
469                &salts.shared_secret,
470                Some(&salts.verifying_keys_mac),
471                csprng,
472            );
473
474        let verifier_mac =
475            verifier.finalized_mac(&mac_shared_secret.expect("Infallible due to last call"));
476
477        let context = std::any::type_name::<Self>().as_bytes();
478        let digest = binding_digest(
479            &ec_public_key,
480            &pq_ciphertext,
481            self.hashable_bytes(),
482            recipient,
483        );
484        let (ec_signature, pq_signature) = signing_keys.sign_digest(context, digest, csprng);
485
486        let wrapper = W::from_parts(self, ec_signature, pq_signature, verifier_mac);
487
488        Ok(A::from_parts(
489            ec_public_key,
490            pq_ciphertext,
491            cipher.encrypt(&nonce, wrapper.encode_to_vec().as_slice())?,
492        ))
493    }
494}
495
496impl PublicKeyEncryptable<Credentials> for Key {}
497
498/// Authenticates encrypted data using SIGMA-I-style verification.
499///
500/// Provides the authentication verification for SIGMA-I-style authenticated
501/// encryption, ensuring the signatures are valid for the cryptographic binding
502/// of ephemeral material and participant public keys.
503pub trait Authenticate<T>
504where
505    T: PublicKeyEncryptable<Self>,
506    Self: Sized,
507{
508    /// Verify the authentication of encrypted data.
509    ///
510    /// Reconstructs the SIGMA-I-style binding digest using the encrypted data's
511    /// ephemeral material and verifies both the Ed25519 and FN-DSA signatures
512    /// against it. This proves the ephemeral material and authenticating keys
513    /// were bound together by the original sender.
514    ///
515    /// Both signatures need to be valid for the reconstructed binding digest to
516    /// authenticate the encrypted data.
517    fn authenticate<E, R, V>(&self, encrypted: &E, recipient: &R, verifier: &V) -> bool
518    where
519        E: PublicKeyEncrypted,
520        R: ParticipantPublicKeys,
521        V: Verifier + ?Sized,
522        Self: Decryptable<E> + Signatures + Digestible,
523    {
524        let digest = binding_digest(
525            encrypted.ec_public_key(),
526            encrypted.pq_ciphertext(),
527            self.hashable_bytes(),
528            recipient,
529        );
530
531        let context = std::any::type_name::<T>().as_bytes();
532
533        verify_digest(
534            verifier,
535            context,
536            digest,
537            self.ec_signature(),
538            self.pq_signature(),
539        )
540    }
541}
542
543impl Authenticate<Key> for Credentials {}
544
545/// Secret keys that "take" (decrypt) hints using ECDH-OMR.
546pub trait HintTaker {
547    /// Decrypt a single envelope ID hint.
548    fn take_the_hint(
549        &self,
550        envelope_id_hint: &EnvelopeIdHint,
551        salts: &Salts,
552    ) -> Result<HintedEnvelopeId, error::CryptError>
553    where
554        Self: ParticipantSecretKeys,
555    {
556        let decrypted_bytes = self
557            .ec_secret_key()
558            .take_the(envelope_id_hint, &salts.shared_secret)?;
559
560        Ok(HintedEnvelopeId::decode(decrypted_bytes)?)
561    }
562
563    /// Decrypt a set of envelope ID hints.
564    fn take_all_the_hints(
565        &self,
566        envelope_id_hints: &EnvelopeIdHints,
567        salts: &Salts,
568    ) -> Result<Vec<HintedEnvelopeId>, error::DecodeError>
569    where
570        Self: ParticipantSecretKeys,
571    {
572        self.ec_secret_key()
573            .take_all_the(envelope_id_hints, &salts.shared_secret)
574            .iter()
575            .map(HintedEnvelopeId::decode)
576            .collect()
577    }
578}
579
580/// Encrypt a symmetric encryption key for multiple recipients using
581/// SIGMA-I-style authenticated encryption.
582///
583/// Creates individual [`CredentialVault`]s for each recipient, allowing them to
584/// decrypt the shared key while maintaining authentication.
585pub fn authenticated_encrypt_key<S, V>(
586    key: &Key,
587    signing_keys: &S,
588    verifier: &V,
589    recipients: &[impl ParticipantPublicKeys],
590    salts: &Salts,
591    csprng: &mut impl CryptoRngCore,
592) -> Result<Vec<CredentialVault>, error::CryptError>
593where
594    S: Sign + VerifyingKeys<V>,
595    V: Verifier,
596{
597    recipients
598        .iter()
599        .map(|p| key.authenticated_encrypt(signing_keys, verifier, p, salts, csprng))
600        .collect()
601}
602
603/// Build an [`EnvelopeSeed`] containing all components needed for secure
604/// message delivery.
605///
606/// Assembles the complete envelope seed structure that includes
607/// [`BlindedPublicKey`]s for anonymous messaging, [`CredentialVault`]s for key
608/// distribution, and the encrypted [`MessageVaultPassport`].
609pub fn build_envelope_seed(
610    message_vault_passport: MessageVaultPassport,
611    participants: &[impl ParticipantPublicKeys],
612    key: &Key,
613    credential_vaults: Vec<CredentialVault>,
614    csprng: &mut impl CryptoRngCore,
615) -> Result<EnvelopeSeed, error::CryptError> {
616    let blinded_public_keys = participants
617        .iter()
618        .map(|p| blind_public_key(p, csprng))
619        .collect();
620
621    let (nonce, message_vault_passport_ciphertext) =
622        message_vault_passport.encrypt_owned(key, csprng)?;
623
624    Ok(EnvelopeSeed {
625        blinded_public_keys,
626        credential_vaults,
627        nonce,
628        message_vault_passport_ciphertext,
629    })
630}