From b4157954162063ff76206cb20955a48c6c6fc4bc Mon Sep 17 00:00:00 2001 From: Burak Date: Tue, 11 Nov 2025 00:38:36 -0500 Subject: [PATCH] Add RustCrypto backend for MUSL/Docker compatibility (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add RustCrypto backend for MUSL/Docker compatibility Implement a pure-Rust cryptographic backend using the RustCrypto ecosystem to enable deployment in MUSL/Docker environments without OpenSSL dependencies. This change adds a new 'backend-rustcrypto' feature flag that provides a fully-functional alternative to the existing OpenSSL backend. The new backend uses p256 for elliptic curve operations, aes-gcm for encryption, and other RustCrypto crates for cryptographic primitives. Key changes: - Add RustCryptoCryptographer implementing the Cryptographer trait - Add backend-rustcrypto feature with pure-Rust dependencies - Implement full interoperability between OpenSSL and RustCrypto backends - Add comprehensive test suite including cross-backend interop tests - Update documentation with usage examples for both backends All RFC 8291 test vectors pass with both backends. The implementation is backward compatible with no breaking changes to the existing API. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Make tests backend-agnostic and simplify interop tests - Remove backend-openssl gates from aes128gcm_tests module - Make all RFC test vectors run with both OpenSSL and RustCrypto - Simplify interop tests to single minimal test - Fix backend-specific error matching in truncated_auth_secret test Test coverage: - RustCrypto: 23 tests (up from 11) - OpenSSL: 29 tests (unchanged) - Both: 30 tests (includes interop) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix clippy warnings and apply rustfmt - Add module-level #![allow] attributes for deprecated and dead_code - Remove AES_GCM_NONCE_LENGTH constant, use literal value - Remove inline allow attributes (now at module level) - Clean up comments for deprecated API usage All clippy checks pass with -D warnings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Improve allow attributes with better documentation Replace blanket #![allow(dead_code)] with targeted #[allow(dead_code)] on specific types and impl blocks. Add clear comments explaining: - deprecated: Due to generic-array < 1.0 in aes-gcm 0.10 (stable). Will be resolved when aes-gcm 0.11 is released. - dead_code: Types appear unused when both backends are enabled because OpenSSL takes precedence, but they're required by the Cryptographer trait implementation and used in tests. This makes the allow attributes more maintainable and documents why they exist. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 5 + README.md | 28 ++++- src/crypto/holder.rs | 10 +- src/crypto/mod.rs | 35 +++++- src/crypto/rustcrypto.rs | 233 +++++++++++++++++++++++++++++++++++++++ src/error.rs | 4 + src/lib.rs | 7 +- 7 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 src/crypto/rustcrypto.rs 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), }; }