anubis_age/pqc/
x25519.rs

1//! X25519 key exchange for classical (non-post-quantum) security.
2//!
3//! This module provides X25519 ECDH for use in hybrid mode, combining
4//! classical proven security with post-quantum security from ML-KEM-1024.
5//!
6//! ## Security Properties
7//!
8//! - **Classical Security**: 128-bit (Discrete Log Problem)
9//! - **Quantum Security**: ❌ Broken by Shor's algorithm
10//! - **Use Case**: Hybrid mode only (combined with ML-KEM-1024)
11//!
12//! ## Example
13//!
14//! ```rust
15//! use anubis_age::pqc::x25519;
16//!
17//! // Generate X25519 keypair
18//! let identity = x25519::Identity::generate();
19//! let recipient = identity.to_public();
20//!
21//! // In practice, use hybrid mode which combines this with ML-KEM-1024
22//! ```
23
24use rand::rngs::OsRng;
25use std::fmt;
26use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
27
28use anubis_core::{
29    format::{FileKey, Stanza, FILE_KEY_BYTES},
30    primitives::{aead_decrypt, aead_encrypt, hkdf},
31    secrecy::ExposeSecret,
32};
33use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
34use bech32::{ToBase32, Variant};
35use zeroize::{Zeroize, Zeroizing};
36
37use crate::{
38    error::{DecryptError, EncryptError},
39    util::read::base64_arg,
40};
41
42const X25519_EPK_LABEL: &str = "X25519";
43const X25519_RECIPIENT_TAG: &str = "X25519";
44
45/// An X25519 secret key (for decryption).
46///
47/// This is the long-term identity that corresponds to an X25519 recipient.
48pub struct Identity(StaticSecret);
49
50impl Identity {
51    /// Generates a new random X25519 identity.
52    pub fn generate() -> Self {
53        let mut rng = OsRng;
54        Identity(StaticSecret::random_from_rng(&mut rng))
55    }
56
57    /// Returns the public key (recipient) corresponding to this identity.
58    pub fn to_public(&self) -> Recipient {
59        Recipient(PublicKey::from(&self.0))
60    }
61
62    /// Performs X25519 Diffie-Hellman with an ephemeral public key.
63    pub(crate) fn diffie_hellman(&self, epk: &[u8; 32]) -> Result<[u8; 32], DecryptError> {
64        let epk = PublicKey::from(*epk);
65        let shared_secret = self.0.diffie_hellman(&epk);
66        Ok(*shared_secret.as_bytes())
67    }
68
69    /// Attempts to unwrap the given X25519 stanza with this identity.
70    fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
71        if stanza.tag != X25519_RECIPIENT_TAG {
72            return None;
73        }
74
75        // Check stanza format: X25519 stanza has 1 arg (EPK) and body contains encrypted file key
76        if stanza.args.len() != 1 {
77            return Some(Err(DecryptError::InvalidHeader));
78        }
79
80        const ENCRYPTED_FILE_KEY_BYTES: usize = FILE_KEY_BYTES + 16;
81        if stanza.body.len() != ENCRYPTED_FILE_KEY_BYTES {
82            return Some(Err(DecryptError::InvalidHeader));
83        }
84
85        // Parse ephemeral public key
86        let epk_bytes = match base64_arg::<_, 32, 64>(&stanza.args[0]) {
87            Some(bytes) => bytes,
88            None => return Some(Err(DecryptError::InvalidHeader)),
89        };
90        let epk = PublicKey::from(epk_bytes);
91
92        // Perform X25519 ECDH
93        let shared_secret = self.0.diffie_hellman(&epk);
94
95        // Derive wrapping key with HKDF
96        let mut salt = Vec::with_capacity(64);
97        salt.extend_from_slice(PublicKey::from(&self.0).as_bytes());
98        salt.extend_from_slice(&epk_bytes);
99
100        let wrap_key = hkdf(
101            &salt,
102            b"age-encryption.org/v1/X25519",
103            shared_secret.as_bytes(),
104        );
105
106        // Decrypt file key
107        aead_decrypt(&Zeroizing::new(wrap_key), FILE_KEY_BYTES, &stanza.body)
108            .ok()
109            .map(|mut plaintext| {
110                Ok(FileKey::init_with_mut(|file_key| {
111                    file_key.copy_from_slice(&plaintext);
112                    plaintext.zeroize();
113                }))
114            })
115    }
116}
117
118impl crate::Identity for Identity {
119    fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
120        Identity::unwrap_stanza(self, stanza)
121    }
122}
123
124/// An X25519 public key (for encryption).
125///
126/// This is the recipient that files can be encrypted to.
127#[derive(Clone)]
128pub struct Recipient(PublicKey);
129
130impl Recipient {
131    /// Returns the underlying X25519 public key.
132    pub(crate) fn public_key(&self) -> &PublicKey {
133        &self.0
134    }
135
136    /// Wraps a file key to this X25519 recipient.
137    fn wrap_file_key(&self, file_key: &FileKey) -> Result<Vec<Stanza>, EncryptError> {
138        let mut rng = OsRng;
139
140        // Generate ephemeral X25519 key pair
141        let esk = EphemeralSecret::random_from_rng(&mut rng);
142        let epk = PublicKey::from(&esk);
143
144        // Perform X25519 ECDH
145        let shared_secret = esk.diffie_hellman(&self.0);
146
147        // Derive wrapping key with HKDF
148        let mut salt = Vec::with_capacity(64);
149        salt.extend_from_slice(self.0.as_bytes());
150        salt.extend_from_slice(epk.as_bytes());
151
152        let wrap_key = hkdf(
153            &salt,
154            b"age-encryption.org/v1/X25519",
155            shared_secret.as_bytes(),
156        );
157
158        // Encrypt file key
159        let encrypted_file_key = aead_encrypt(&Zeroizing::new(wrap_key), file_key.expose_secret());
160
161        Ok(vec![Stanza {
162            tag: X25519_RECIPIENT_TAG.to_string(),
163            args: vec![BASE64_STANDARD_NO_PAD.encode(epk.as_bytes())],
164            body: encrypted_file_key,
165        }])
166    }
167}
168
169impl crate::Recipient for Recipient {
170    fn wrap_file_key(
171        &self,
172        file_key: &FileKey,
173    ) -> Result<(Vec<Stanza>, std::collections::HashSet<String>), EncryptError> {
174        // X25519 uses empty label set (can be combined with any recipient)
175        Ok((self.wrap_file_key(file_key)?, std::collections::HashSet::new()))
176    }
177}
178
179impl fmt::Display for Recipient {
180    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
181        write!(
182            f,
183            "age1{}",
184            bech32::encode(
185                "x25519",
186                self.0.as_bytes().to_base32(),
187                Variant::Bech32
188            )
189            .map_err(|_| fmt::Error)?
190        )
191    }
192}
193
194impl fmt::Display for Identity {
195    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
196        write!(
197            f,
198            "AGE-SECRET-KEY-1{}",
199            bech32::encode(
200                "",
201                self.0.to_bytes().to_base32(),
202                Variant::Bech32
203            )
204            .map_err(|_| fmt::Error)?
205            .to_uppercase()
206        )
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn x25519_round_trip() {
216        let identity = Identity::generate();
217        let recipient = identity.to_public();
218
219        let file_key = FileKey::new(Box::new([42; 16]));
220
221        // Encrypt
222        let stanzas = recipient.wrap_file_key(&file_key).unwrap();
223        assert_eq!(stanzas.len(), 1);
224        assert_eq!(stanzas[0].tag, X25519_RECIPIENT_TAG);
225
226        // Decrypt
227        let decrypted = identity.unwrap_stanza(&stanzas[0]).unwrap().unwrap();
228        assert_eq!(decrypted.expose_secret(), file_key.expose_secret());
229    }
230
231    #[test]
232    fn x25519_public_key_encoding() {
233        let identity = Identity::generate();
234        let recipient = identity.to_public();
235
236        let encoded = recipient.to_string();
237        assert!(encoded.starts_with("age1"));
238        assert!(encoded.len() > 10);
239    }
240}