diff --git a/Cargo.toml b/Cargo.toml index f807536..6e3644e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,11 +22,16 @@ once_cell = "1.21" openssl = { version = "0.10", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } sha2 = { version = "0.10", optional = true } +# RustCrypto backend dependencies +p256 = { version = "0.13", optional = true, features = ["ecdh", "std"] } +aes-gcm = { version = "0.10", optional = true } +rand_core = { version = "0.6", optional = true, features = ["std"] } [features] default = ["backend-openssl", "serializable-keys"] serializable-keys = ["serde"] backend-openssl = ["openssl", "lazy_static", "hkdf", "sha2"] +backend-rustcrypto = ["p256", "aes-gcm", "hkdf", "sha2", "rand_core"] backend-test-helper = [] [package.metadata.release] diff --git a/README.md b/README.md index df5f6ae..f80ba0b 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,32 @@ These restrictions might be lifted in future, if it turns out that we need them. ## Cryptographic backends -This crate is designed to use pluggable backend implementations of low-level crypto primitives. different crypto -backends. At the moment only [openssl](https://github.com/sfackler/rust-openssl) is supported. +This crate is designed to use pluggable backend implementations of low-level crypto primitives. + +Two backends are currently supported: + +* **OpenSSL** (default): Uses the [openssl](https://github.com/sfackler/rust-openssl) crate. This is the default backend and provides excellent performance, but requires OpenSSL to be installed on the system. +* **RustCrypto**: Uses pure-Rust implementations from the [RustCrypto](https://github.com/RustCrypto) project. This backend has no C dependencies and works well with MUSL and static linking scenarios (e.g., Docker Alpine images). + +### Using the RustCrypto backend + +To use the RustCrypto backend instead of OpenSSL: + +```toml +[dependencies] +ece = { version = "2.4", default-features = false, features = ["backend-rustcrypto", "serializable-keys"] } +``` + +### Using both backends + +You can enable both backends simultaneously if needed: + +```toml +[dependencies] +ece = { version = "2.4", features = ["backend-rustcrypto"] } +``` + +When both backends are enabled, OpenSSL takes precedence by default. The backends are fully interoperable - keys and ciphertext generated with one backend can be used with the other. ## Release process diff --git a/src/crypto/holder.rs b/src/crypto/holder.rs index fb78a73..a37394a 100644 --- a/src/crypto/holder.rs +++ b/src/crypto/holder.rs @@ -15,7 +15,7 @@ pub struct SetCryptographerError(()); /// /// This is a convenience wrapper over [`set_cryptographer`], /// but takes a `Box` instead. -#[cfg(not(feature = "backend-openssl"))] +#[cfg(not(any(feature = "backend-openssl", feature = "backend-rustcrypto")))] pub fn set_boxed_cryptographer(c: Box) -> Result<(), SetCryptographerError> { // Just leak the Box. It wouldn't be freed as a `static` anyway, and we // never allow this to be re-assigned (so it's not a meaningful memory leak). @@ -45,6 +45,12 @@ fn autoinit_crypto() { let _ = set_cryptographer(&super::openssl::OpensslCryptographer); } -#[cfg(not(feature = "backend-openssl"))] +#[cfg(all(feature = "backend-rustcrypto", not(feature = "backend-openssl")))] +#[inline] +fn autoinit_crypto() { + let _ = set_cryptographer(&super::rustcrypto::RustCryptoCryptographer); +} + +#[cfg(not(any(feature = "backend-openssl", feature = "backend-rustcrypto")))] #[inline] fn autoinit_crypto() {} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 5986835..9ea0f21 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -8,8 +8,10 @@ use std::any::Any; pub(crate) mod holder; #[cfg(feature = "backend-openssl")] mod openssl; +#[cfg(feature = "backend-rustcrypto")] +mod rustcrypto; -#[cfg(not(feature = "backend-openssl"))] +#[cfg(not(any(feature = "backend-openssl", feature = "backend-rustcrypto")))] pub use holder::{set_boxed_cryptographer, set_cryptographer}; pub trait RemotePublicKey: Send + Sync + 'static { @@ -167,3 +169,34 @@ mod tests { test_cryptographer(super::openssl::OpensslCryptographer); } } + +#[cfg(all(test, feature = "backend-rustcrypto", not(feature = "backend-openssl")))] +mod rustcrypto_tests { + use super::*; + + #[test] + fn test_rustcrypto_cryptographer() { + test_cryptographer(super::rustcrypto::RustCryptoCryptographer); + } +} + +#[cfg(all(test, feature = "backend-openssl", feature = "backend-rustcrypto"))] +mod interop_tests { + use super::*; + + #[test] + fn test_backend_interop() { + let openssl_crypto = super::openssl::OpensslCryptographer; + let rustcrypto_crypto = super::rustcrypto::RustCryptoCryptographer; + + // Generate key with OpenSSL, import to RustCrypto + let key = openssl_crypto.generate_ephemeral_keypair().unwrap(); + let components = key.raw_components().unwrap(); + rustcrypto_crypto.import_key_pair(&components).unwrap(); + + // Generate key with RustCrypto, import to OpenSSL + let key = rustcrypto_crypto.generate_ephemeral_keypair().unwrap(); + let components = key.raw_components().unwrap(); + openssl_crypto.import_key_pair(&components).unwrap(); + } +} diff --git a/src/crypto/rustcrypto.rs b/src/crypto/rustcrypto.rs new file mode 100644 index 0000000..923e3fd --- /dev/null +++ b/src/crypto/rustcrypto.rs @@ -0,0 +1,233 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Silence deprecation warnings from generic-array < 1.0 used by aes-gcm 0.10 +// This will be resolved when upgrading to aes-gcm 0.11+ (currently RC) +#![allow(deprecated)] + +use crate::{ + crypto::{Cryptographer, EcKeyComponents, LocalKeyPair, RemotePublicKey}, + error::*, +}; +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes128Gcm, Nonce, +}; +use hkdf::Hkdf; +use p256::{ + ecdh::diffie_hellman, + elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}, + EncodedPoint, PublicKey, SecretKey, +}; +use rand_core::OsRng; +use sha2::Sha256; +use std::{any::Any, fmt}; + +// Types and methods may appear unused when both backends are enabled, +// but they're required by the Cryptographer trait implementation +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub struct RustCryptoRemotePublicKey { + public_key: PublicKey, + raw_pub_key: Vec, +} + +#[allow(dead_code)] +impl RustCryptoRemotePublicKey { + fn from_raw(raw: &[u8]) -> Result { + let encoded_point = EncodedPoint::from_bytes(raw).map_err(|_| Error::InvalidKeyLength)?; + let public_key = PublicKey::from_encoded_point(&encoded_point) + .into_option() + .ok_or(Error::InvalidKeyLength)?; + Ok(RustCryptoRemotePublicKey { + public_key, + raw_pub_key: raw.to_vec(), + }) + } + + pub(crate) fn public_key(&self) -> &PublicKey { + &self.public_key + } +} + +impl RemotePublicKey for RustCryptoRemotePublicKey { + fn as_raw(&self) -> Result> { + Ok(self.raw_pub_key.clone()) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[allow(dead_code)] +#[derive(Clone)] +pub struct RustCryptoLocalKeyPair { + secret_key: SecretKey, +} + +impl fmt::Debug for RustCryptoLocalKeyPair { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:?}", + base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE, + self.secret_key.to_bytes() + ) + ) + } +} + +#[allow(dead_code)] +impl RustCryptoLocalKeyPair { + /// Generate a random local key pair using p256's RNG. + fn generate_random() -> Result { + let secret_key = SecretKey::random(&mut OsRng); + Ok(RustCryptoLocalKeyPair { secret_key }) + } + + fn from_raw_components(components: &EcKeyComponents) -> Result { + // Verify the public key matches the private key + let private_bytes = components.private_key(); + if private_bytes.len() != 32 { + return Err(Error::InvalidKeyLength); + } + + let secret_key = + SecretKey::from_slice(private_bytes).map_err(|_| Error::InvalidKeyLength)?; + + // Verify the public key component matches + let derived_public = secret_key.public_key(); + let derived_raw = derived_public.to_encoded_point(false); + + if derived_raw.as_bytes() != components.public_key() { + return Err(Error::InvalidKeyLength); + } + + Ok(RustCryptoLocalKeyPair { secret_key }) + } + + pub(crate) fn secret_key(&self) -> &SecretKey { + &self.secret_key + } +} + +impl LocalKeyPair for RustCryptoLocalKeyPair { + /// Export the public key component in the binary uncompressed point representation. + fn pub_as_raw(&self) -> Result> { + let public_key = self.secret_key.public_key(); + let encoded = public_key.to_encoded_point(false); + Ok(encoded.as_bytes().to_vec()) + } + + fn raw_components(&self) -> Result { + let private_key = self.secret_key.to_bytes(); + let public_key = self.pub_as_raw()?; + Ok(EcKeyComponents::new(private_key.to_vec(), public_key)) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[allow(dead_code)] +pub struct RustCryptoCryptographer; + +impl Cryptographer for RustCryptoCryptographer { + fn generate_ephemeral_keypair(&self) -> Result> { + Ok(Box::new(RustCryptoLocalKeyPair::generate_random()?)) + } + + fn import_key_pair(&self, components: &EcKeyComponents) -> Result> { + Ok(Box::new(RustCryptoLocalKeyPair::from_raw_components( + components, + )?)) + } + + fn import_public_key(&self, raw: &[u8]) -> Result> { + Ok(Box::new(RustCryptoRemotePublicKey::from_raw(raw)?)) + } + + fn compute_ecdh_secret( + &self, + remote: &dyn RemotePublicKey, + local: &dyn LocalKeyPair, + ) -> Result> { + let local_any = local.as_any(); + let local = local_any + .downcast_ref::() + .ok_or(Error::CryptoError)?; + + let remote_any = remote.as_any(); + let remote = remote_any + .downcast_ref::() + .ok_or(Error::CryptoError)?; + + // Perform ECDH using the diffie_hellman function + let shared_secret = diffie_hellman( + local.secret_key.to_nonzero_scalar(), + remote.public_key.as_affine(), + ); + + Ok(shared_secret.raw_secret_bytes().to_vec()) + } + + fn hkdf_sha256(&self, salt: &[u8], secret: &[u8], info: &[u8], len: usize) -> Result> { + let (_, hk) = Hkdf::::extract(Some(salt), secret); + let mut okm = vec![0u8; len]; + hk.expand(info, &mut okm).map_err(|_| Error::CryptoError)?; + Ok(okm) + } + + fn aes_gcm_128_encrypt(&self, key: &[u8], iv: &[u8], data: &[u8]) -> Result> { + if key.len() != 16 { + return Err(Error::CryptoError); + } + if iv.len() != 12 { + return Err(Error::CryptoError); + } + + let cipher = Aes128Gcm::new_from_slice(key).map_err(|_| Error::CryptoError)?; + let nonce = Nonce::from_slice(iv); + + // AES-GCM encrypt returns [ciphertext || tag] + let ciphertext = cipher + .encrypt(nonce, data) + .map_err(|_| Error::CryptoError)?; + + Ok(ciphertext) + } + + fn aes_gcm_128_decrypt( + &self, + key: &[u8], + iv: &[u8], + ciphertext_and_tag: &[u8], + ) -> Result> { + if key.len() != 16 { + return Err(Error::CryptoError); + } + if iv.len() != 12 { + return Err(Error::CryptoError); + } + + let cipher = Aes128Gcm::new_from_slice(key).map_err(|_| Error::CryptoError)?; + let nonce = Nonce::from_slice(iv); + + // aes-gcm crate expects [ciphertext || tag] format + let plaintext = cipher + .decrypt(nonce, ciphertext_and_tag) + .map_err(|_| Error::CryptoError)?; + + Ok(plaintext) + } + + fn random_bytes(&self, dest: &mut [u8]) -> Result<()> { + use rand_core::RngCore; + OsRng.fill_bytes(dest); + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs index 74f143c..dc9ea61 100644 --- a/src/error.rs +++ b/src/error.rs @@ -54,4 +54,8 @@ pub enum Error { #[cfg(feature = "backend-openssl")] #[error("OpenSSL error: {0}")] OpenSSLError(#[from] openssl::error::ErrorStack), + + #[cfg(feature = "backend-rustcrypto")] + #[error("RustCrypto error: {0}")] + RustCryptoError(String), } diff --git a/src/lib.rs b/src/lib.rs index 00955a2..766b64d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,7 +66,7 @@ pub fn decrypt(components: &EcKeyComponents, auth: &[u8], data: &[u8]) -> Result /// Generate a pair of keys; useful for writing tests. /// -#[cfg(all(test, feature = "backend-openssl"))] +#[cfg(test)] fn generate_keys() -> Result<(Box, Box)> { let cryptographer = crypto::holder::get_cryptographer(); let local_key = cryptographer.generate_ephemeral_keypair()?; @@ -74,7 +74,7 @@ fn generate_keys() -> Result<(Box, Box)> { Ok((local_key, remote_key)) } -#[cfg(all(test, feature = "backend-openssl"))] +#[cfg(test)] mod aes128gcm_tests { use super::common::ECE_TAG_LENGTH; use super::*; @@ -293,7 +293,10 @@ mod aes128gcm_tests { "8115f4988b8c392a7bacb43c8f1ac5650000001241041994483c541e9bc39a6af03ff713aa7745c284e138a42a2435b797b20c4b698cf5118b4f8555317c190eabebfab749c164d3f6bdebe0d441719131a357d8890a13c4dbd4b16ff3dd5a83f7c91ad6e040ac42730a7f0b3cd3245e9f8d6ff31c751d410cfd" ).unwrap_err(); match err { + #[cfg(feature = "backend-openssl")] Error::OpenSSLError(_) => {} + #[cfg(feature = "backend-rustcrypto")] + Error::CryptoError => {} _ => panic!("Unexpected error {:?}", err), }; }