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}