Skip to main content

dynomite/crypto/
mod.rs

1//! Cryptographic primitives used by the DNODE peer protocol.
2//!
3//! The engine encrypts inter-node payloads with a per-pool symmetric
4//! AES key. The key itself is wrapped with the recipient's RSA public
5//! key and exchanged during the DNODE handshake. This module exposes:
6//!
7//! * [`Crypto`] - bundle of an RSA key pair (loaded from PEM) and a
8//!   freshly generated 32-byte AES key buffer. Construct it with
9//!   [`Crypto::from_pem`] at process startup.
10//! * AES-128-CBC encryption and decryption (the cipher consumes the
11//!   first 16 bytes of the 32-byte key buffer; the IV is the same
12//!   16 bytes), including helpers that pipe through the
13//!   [`MbufQueue`](crate::io::mbuf::MbufQueue) chain the rest of the
14//!   engine uses.
15//! * RSA wrap and unwrap of the symmetric key, using PKCS#1 OAEP
16//!   padding.
17//! * Base64 encoding and decoding wrappers around the workspace
18//!   `base64` crate.
19//! * PEM key loading for both PKCS#1 (`-----BEGIN RSA PRIVATE KEY-----`)
20//!   and PKCS#8 (`-----BEGIN PRIVATE KEY-----`) framings.
21//!
22//! # Examples
23//!
24//! ```
25//! use dynomite::crypto::Crypto;
26//!
27//! let key = Crypto::generate_aes_key().unwrap();
28//! let plain = b"hello dnode";
29//! let cipher = Crypto::aes_encrypt(plain, &key).unwrap();
30//! assert_ne!(cipher.as_slice(), plain);
31//! let round = Crypto::aes_decrypt(&cipher, &key).unwrap();
32//! assert_eq!(round, plain);
33//! ```
34
35use std::io;
36use std::path::Path;
37
38use rand::RngCore;
39use thiserror::Error;
40
41use ::rsa::traits::PublicKeyParts;
42use ::rsa::RsaPrivateKey;
43
44pub mod aes;
45pub mod base64;
46pub mod pem;
47pub mod rsa;
48
49pub use self::aes::{AES_BLOCK_SIZE, AES_KEYLEN};
50pub use self::base64::{base64_decode, base64_encode};
51
52/// Errors produced by the crypto module.
53///
54/// Variants enumerate the small fixed set of failure modes the engine
55/// reports up to its callers. The opaque OpenSSL error stack is
56/// preserved when relevant so operators can correlate failures with
57/// the underlying library log.
58#[derive(Debug, Error)]
59pub enum CryptoError {
60    /// A symmetric or asymmetric key was malformed or had the wrong
61    /// length.
62    #[error("invalid key material")]
63    InvalidKey,
64
65    /// A PEM file did not contain a recognisable RSA private key.
66    #[error("invalid PEM input: {0}")]
67    InvalidPem(String),
68
69    /// Symmetric or asymmetric encryption failed.
70    #[error("encryption failed")]
71    EncryptionFailed,
72
73    /// Symmetric or asymmetric decryption failed.
74    #[error("decryption failed")]
75    DecryptionFailed,
76
77    /// PKCS#7 padding on a decrypted block was malformed.
78    #[error("bad PKCS#7 padding")]
79    BadPadding,
80
81    /// A base64 input was malformed.
82    #[error("base64 decode failed: {0}")]
83    Base64(String),
84
85    /// Underlying I/O failure (file open, read, write).
86    #[error(transparent)]
87    Io(#[from] io::Error),
88}
89
90/// Bundle of crypto state used by a Dynomite peer instance.
91///
92/// Holds an RSA key pair loaded from a PEM file and a fresh 32-byte
93/// AES session key buffer generated when the bundle is constructed.
94/// The session key is used for symmetric encryption of DNODE
95/// payloads (AES-128-CBC consumes the first 16 bytes; the remaining
96/// 16 bytes match the C `aes_key[AES_KEYLEN]` buffer layout), while
97/// the RSA pair is used to wrap and unwrap session keys during the
98/// handshake.
99///
100/// # Examples
101///
102/// ```no_run
103/// use dynomite::crypto::Crypto;
104///
105/// let crypto = Crypto::from_pem("conf/dynomite.pem").unwrap();
106/// let payload = b"sample";
107/// let cipher = Crypto::aes_encrypt(payload, crypto.aes_key()).unwrap();
108/// let plain = Crypto::aes_decrypt(&cipher, crypto.aes_key()).unwrap();
109/// assert_eq!(plain, payload);
110/// ```
111pub struct Crypto {
112    aes_key: [u8; AES_KEYLEN],
113    rsa: RsaPrivateKey,
114}
115
116impl Crypto {
117    /// Construct a new bundle by loading an RSA private key from the
118    /// given PEM file and generating a fresh 32-byte AES key buffer
119    /// from the system CSPRNG.
120    ///
121    /// AES-128-CBC consumes only the first 16 bytes of the buffer;
122    /// the trailing 16 bytes match the C `aes_key[AES_KEYLEN]` layout.
123    ///
124    /// # Examples
125    ///
126    /// ```no_run
127    /// use dynomite::crypto::Crypto;
128    /// let crypto = Crypto::from_pem("conf/dynomite.pem").unwrap();
129    /// assert_eq!(crypto.aes_key().len(), 32);
130    /// ```
131    pub fn from_pem<P: AsRef<Path>>(path: P) -> Result<Self, CryptoError> {
132        let rsa = pem::load_rsa_private_key(path.as_ref())?;
133        let aes_key = Self::generate_aes_key()?;
134        Ok(Self { aes_key, rsa })
135    }
136
137    /// Construct a bundle from an already-loaded RSA private key and
138    /// a caller-supplied AES key. Used by tests and embedders that
139    /// want to exercise the bundle without touching the filesystem.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use dynomite::crypto::Crypto;
145    /// use rsa::RsaPrivateKey;
146    /// use rand::rngs::OsRng;
147    ///
148    /// let aes_key = Crypto::generate_aes_key().unwrap();
149    /// let mut rng = OsRng;
150    /// let rsa = RsaPrivateKey::new(&mut rng, 2048).unwrap();
151    /// let crypto = Crypto::from_parts(rsa, aes_key);
152    /// assert_eq!(crypto.aes_key().len(), 32);
153    /// ```
154    pub fn from_parts(rsa: RsaPrivateKey, aes_key: [u8; AES_KEYLEN]) -> Self {
155        Self { aes_key, rsa }
156    }
157
158    /// Generate a fresh 32-byte AES key buffer from the system
159    /// CSPRNG.
160    ///
161    /// The returned slice is 32 random bytes. AES-128-CBC consumes
162    /// only the first 16 bytes; the trailing 16 bytes are kept to
163    /// match the C `aes_key[AES_KEYLEN]` buffer layout.
164    ///
165    /// # Examples
166    ///
167    /// ```
168    /// use dynomite::crypto::Crypto;
169    ///
170    /// let a = Crypto::generate_aes_key().unwrap();
171    /// let b = Crypto::generate_aes_key().unwrap();
172    /// assert_ne!(a, b);
173    /// ```
174    pub fn generate_aes_key() -> Result<[u8; AES_KEYLEN], CryptoError> {
175        let mut key = [0u8; AES_KEYLEN];
176        rand::rngs::OsRng.fill_bytes(&mut key);
177        Ok(key)
178    }
179
180    /// Borrow the bundle's AES session key.
181    ///
182    /// # Examples
183    ///
184    /// ```no_run
185    /// use dynomite::crypto::Crypto;
186    /// let crypto = Crypto::from_pem("conf/dynomite.pem").unwrap();
187    /// assert_eq!(crypto.aes_key().len(), 32);
188    /// ```
189    pub fn aes_key(&self) -> &[u8; AES_KEYLEN] {
190        &self.aes_key
191    }
192
193    /// Borrow the bundle's RSA private key.
194    ///
195    /// # Examples
196    ///
197    /// ```no_run
198    /// use dynomite::crypto::Crypto;
199    /// let crypto = Crypto::from_pem("conf/dynomite.pem").unwrap();
200    /// assert!(crypto.rsa_size() > 0);
201    /// ```
202    pub fn rsa_private_key(&self) -> &RsaPrivateKey {
203        &self.rsa
204    }
205
206    /// Modulus size of the loaded RSA key in bytes.
207    ///
208    /// # Examples
209    ///
210    /// ```no_run
211    /// use dynomite::crypto::Crypto;
212    /// let crypto = Crypto::from_pem("conf/dynomite.pem").unwrap();
213    /// assert!(crypto.rsa_size() >= 128);
214    /// ```
215    pub fn rsa_size(&self) -> usize {
216        self.rsa.size()
217    }
218
219    /// AES-128-CBC encrypt `msg` with `aes_key`. The output is the
220    /// PKCS#7-padded ciphertext with no IV prefix.
221    ///
222    /// # Security
223    ///
224    /// * AES-128 in CBC mode with PKCS#7 padding. The cipher
225    ///   consumes only the first 16 bytes of the 32-byte `aes_key`
226    ///   buffer.
227    /// * The IV is the same 16 bytes as the key. The static IV is
228    ///   a known weakness of the legacy wire protocol; the Rust
229    ///   port faithfully reproduces it for wire compatibility. Two
230    ///   encryptions of the same plaintext
231    ///   under the same key produce identical ciphertext.
232    /// * The output is not authenticated. Integrity is provided by
233    ///   the surrounding DNODE message framing. Embedders that need
234    ///   authenticated payloads should treat this as a
235    ///   transport-layer encryption only and layer an AEAD on top.
236    ///
237    /// # Examples
238    ///
239    /// ```
240    /// use dynomite::crypto::Crypto;
241    /// let key = Crypto::generate_aes_key().unwrap();
242    /// let cipher = Crypto::aes_encrypt(b"hi", &key).unwrap();
243    /// let plain = Crypto::aes_decrypt(&cipher, &key).unwrap();
244    /// assert_eq!(plain, b"hi");
245    /// ```
246    pub fn aes_encrypt(msg: &[u8], aes_key: &[u8; AES_KEYLEN]) -> Result<Vec<u8>, CryptoError> {
247        aes::encrypt_to_vec(msg, aes_key)
248    }
249
250    /// AES-128-CBC decrypt the output of [`Crypto::aes_encrypt`].
251    ///
252    /// `enc` must be a non-empty integral number of 16-byte
253    /// ciphertext blocks. There is no IV prefix; the IV is derived
254    /// from `aes_key` exactly as on the encryption side.
255    ///
256    /// # Examples
257    ///
258    /// ```
259    /// use dynomite::crypto::Crypto;
260    /// let key = Crypto::generate_aes_key().unwrap();
261    /// let cipher = Crypto::aes_encrypt(b"hello", &key).unwrap();
262    /// let plain = Crypto::aes_decrypt(&cipher, &key).unwrap();
263    /// assert_eq!(plain, b"hello");
264    /// ```
265    pub fn aes_decrypt(enc: &[u8], aes_key: &[u8; AES_KEYLEN]) -> Result<Vec<u8>, CryptoError> {
266        aes::decrypt_to_vec(enc, aes_key)
267    }
268
269    /// AES-128-CBC encrypt `msg`, writing the result into a fresh
270    /// `MbufQueue` drawn from `pool`. The chain holds the raw
271    /// ciphertext; there is no IV prefix. Output spans as many
272    /// chunks as needed; each chunk is filled up to the writable
273    /// region before allocating the next one.
274    ///
275    /// # Examples
276    ///
277    /// ```
278    /// use dynomite::crypto::Crypto;
279    /// use dynomite::io::mbuf::MbufPool;
280    ///
281    /// let pool = MbufPool::default();
282    /// let key = Crypto::generate_aes_key().unwrap();
283    /// let mut chain = Crypto::dyn_aes_encrypt(b"hello", &key, &pool).unwrap();
284    /// let plain = Crypto::dyn_aes_decrypt_to_vec(&mut chain, &key).unwrap();
285    /// assert_eq!(plain, b"hello");
286    /// ```
287    pub fn dyn_aes_encrypt(
288        msg: &[u8],
289        aes_key: &[u8; AES_KEYLEN],
290        pool: &crate::io::mbuf::MbufPool,
291    ) -> Result<crate::io::mbuf::MbufQueue, CryptoError> {
292        aes::encrypt_to_chain(msg, aes_key, pool)
293    }
294
295    /// AES-128-CBC decrypt a ciphertext chain produced by
296    /// [`Crypto::dyn_aes_encrypt`], appending the recovered plaintext
297    /// to a fresh `MbufQueue` drawn from `pool`.
298    ///
299    /// `enc` is consumed: chunks are popped off the front and pushed
300    /// to the pool free list as they are drained.
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use dynomite::crypto::Crypto;
306    /// use dynomite::io::mbuf::MbufPool;
307    ///
308    /// let pool = MbufPool::default();
309    /// let key = Crypto::generate_aes_key().unwrap();
310    /// let mut chain = Crypto::dyn_aes_encrypt(b"abc", &key, &pool).unwrap();
311    /// let mut plain_chain = Crypto::dyn_aes_decrypt(&mut chain, &key, &pool).unwrap();
312    /// assert_eq!(plain_chain.total_len(), 3);
313    /// ```
314    pub fn dyn_aes_decrypt(
315        enc: &mut crate::io::mbuf::MbufQueue,
316        aes_key: &[u8; AES_KEYLEN],
317        pool: &crate::io::mbuf::MbufPool,
318    ) -> Result<crate::io::mbuf::MbufQueue, CryptoError> {
319        aes::decrypt_chain_to_chain(enc, aes_key, pool)
320    }
321
322    /// Convenience wrapper that decrypts a ciphertext chain into a
323    /// flat `Vec<u8>`. Useful for tests and protocol code that needs
324    /// the cleartext as a single buffer.
325    ///
326    /// # Examples
327    ///
328    /// ```
329    /// use dynomite::crypto::Crypto;
330    /// use dynomite::io::mbuf::MbufPool;
331    ///
332    /// let pool = MbufPool::default();
333    /// let key = Crypto::generate_aes_key().unwrap();
334    /// let mut chain = Crypto::dyn_aes_encrypt(b"hello", &key, &pool).unwrap();
335    /// let plain = Crypto::dyn_aes_decrypt_to_vec(&mut chain, &key).unwrap();
336    /// assert_eq!(plain, b"hello");
337    /// ```
338    pub fn dyn_aes_decrypt_to_vec(
339        enc: &mut crate::io::mbuf::MbufQueue,
340        aes_key: &[u8; AES_KEYLEN],
341    ) -> Result<Vec<u8>, CryptoError> {
342        let mut bytes = Vec::with_capacity(enc.total_len());
343        while let Some(buf) = enc.pop_front() {
344            bytes.extend_from_slice(buf.readable());
345        }
346        Self::aes_decrypt(&bytes, aes_key)
347    }
348
349    /// AES-128-CBC encrypt the readable region of `msg`, returning a
350    /// new chain holding the ciphertext along with the total number
351    /// of ciphertext bytes written.
352    ///
353    /// The handshake encodes its own framing on top of the returned
354    /// chain, so the output count is reported separately rather than
355    /// derived from the queue.
356    ///
357    /// # Examples
358    ///
359    /// ```
360    /// use dynomite::crypto::Crypto;
361    /// use dynomite::io::mbuf::{Mbuf, MbufPool};
362    ///
363    /// let pool = MbufPool::default();
364    /// let key = Crypto::generate_aes_key().unwrap();
365    /// let mut buf = pool.get();
366    /// buf.recv(b"payload");
367    /// let (mut chain, n) = Crypto::dyn_aes_encrypt_msg(&buf, &key, &pool).unwrap();
368    /// assert!(n > 0);
369    /// let plain = Crypto::dyn_aes_decrypt_to_vec(&mut chain, &key).unwrap();
370    /// assert_eq!(plain, b"payload");
371    /// ```
372    pub fn dyn_aes_encrypt_msg(
373        msg: &crate::io::mbuf::Mbuf,
374        aes_key: &[u8; AES_KEYLEN],
375        pool: &crate::io::mbuf::MbufPool,
376    ) -> Result<(crate::io::mbuf::MbufQueue, usize), CryptoError> {
377        let chain = aes::encrypt_to_chain(msg.readable(), aes_key, pool)?;
378        let n = chain.total_len();
379        Ok((chain, n))
380    }
381
382    /// RSA encrypt `msg` with the bundle's public key using PKCS#1
383    /// OAEP padding (with the OpenSSL default SHA-1 hash and MGF1).
384    /// The output length is the RSA modulus size in bytes (typically
385    /// 128 for 1024-bit keys, 256 for 2048-bit).
386    ///
387    /// # Examples
388    ///
389    /// ```no_run
390    /// use dynomite::crypto::Crypto;
391    /// let crypto = Crypto::from_pem("conf/dynomite.pem").unwrap();
392    /// let key = Crypto::generate_aes_key().unwrap();
393    /// let wrapped = crypto.rsa_encrypt(&key).unwrap();
394    /// let unwrapped = crypto.rsa_decrypt(&wrapped).unwrap();
395    /// assert_eq!(unwrapped, key);
396    /// ```
397    pub fn rsa_encrypt(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
398        rsa::encrypt(&self.rsa, msg)
399    }
400
401    /// RSA decrypt `enc` with the bundle's private key using PKCS#1
402    /// OAEP padding (with the OpenSSL default SHA-1 hash and MGF1).
403    ///
404    /// # Examples
405    ///
406    /// ```no_run
407    /// use dynomite::crypto::Crypto;
408    /// let crypto = Crypto::from_pem("conf/dynomite.pem").unwrap();
409    /// let key = Crypto::generate_aes_key().unwrap();
410    /// let wrapped = crypto.rsa_encrypt(&key).unwrap();
411    /// let unwrapped = crypto.rsa_decrypt(&wrapped).unwrap();
412    /// assert_eq!(unwrapped, key);
413    /// ```
414    pub fn rsa_decrypt(&self, enc: &[u8]) -> Result<Vec<u8>, CryptoError> {
415        rsa::decrypt(&self.rsa, enc)
416    }
417}
418
419impl std::fmt::Debug for Crypto {
420    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421        f.debug_struct("Crypto")
422            .field("aes_key_len", &self.aes_key.len())
423            .field("rsa_size", &self.rsa_size())
424            .finish_non_exhaustive()
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn generate_aes_key_returns_distinct_keys() {
434        let a = Crypto::generate_aes_key().unwrap();
435        let b = Crypto::generate_aes_key().unwrap();
436        assert_eq!(a.len(), AES_KEYLEN);
437        assert_ne!(a, b);
438    }
439
440    #[test]
441    fn aes_round_trip_short() {
442        let key = Crypto::generate_aes_key().unwrap();
443        for plain in &[&b""[..], b"a", b"abcdefghij", b"this is a test"] {
444            let cipher = Crypto::aes_encrypt(plain, &key).unwrap();
445            assert!(cipher.len() >= AES_BLOCK_SIZE);
446            assert_eq!(cipher.len() % AES_BLOCK_SIZE, 0);
447            let round = Crypto::aes_decrypt(&cipher, &key).unwrap();
448            assert_eq!(round.as_slice(), *plain);
449        }
450    }
451
452    #[test]
453    fn debug_does_not_leak_key() {
454        let aes = [0u8; AES_KEYLEN];
455        let mut rng = rand::rngs::OsRng;
456        let rsa = RsaPrivateKey::new(&mut rng, 2048).unwrap();
457        let c = Crypto::from_parts(rsa, aes);
458        let s = format!("{c:?}");
459        assert!(s.contains("Crypto"));
460        assert!(!s.contains("0, 0, 0, 0"));
461    }
462}