use base64::Engine; use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce, aead::Aead}; use hkdf::Hkdf; use rsa::{ Oaep, RsaPrivateKey, RsaPublicKey, pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, EncodeRsaPublicKey}, }; use serde::{Deserialize, Serialize}; use sha2::Sha256; use crate::error::AppError; use crate::service::AuthService; use crate::session::Session; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct RsaResponse { pub public_key: String, } impl AuthService { pub const RSA_PRIVATE_KEY: &'static str = "rsa:private"; pub const RSA_PUBLIC_KEY: &'static str = "rsa:public"; const RSA_BIT_SIZE: usize = 2048; fn derive_rsa_encryption_key(&self) -> Result<[u8; 32], AppError> { let secret = self .ctx .config .env .get("APP_SESSION_SECRET") .map(|s| s.trim()) .filter(|s| !s.is_empty()) .ok_or_else(|| AppError::Config("APP_SESSION_SECRET is required".into()))?; let hk = Hkdf::::new(Some(b"rsa-session-encryption"), secret.as_bytes()); let mut okm = [0u8; 32]; hk.expand(b"rsa-private-key-aead", &mut okm) .map_err(|_| AppError::RsaGenerationError)?; Ok(okm) } fn encrypt_rsa_key(&self, plaintext: &str) -> Result { let key = self.derive_rsa_encryption_key()?; let cipher = ChaCha20Poly1305::new_from_slice(&key) .expect("32-byte key is valid for ChaCha20Poly1305"); let nonce_bytes: [u8; 12] = rand::random(); let nonce = Nonce::from(nonce_bytes); let ciphertext = cipher .encrypt(&nonce, plaintext.as_bytes()) .map_err(|_| AppError::RsaGenerationError)?; let mut combined = nonce_bytes.to_vec(); combined.extend_from_slice(&ciphertext); Ok(base64::engine::general_purpose::STANDARD.encode(&combined)) } fn decrypt_rsa_key(&self, encrypted: &str) -> Result { let key = self.derive_rsa_encryption_key()?; let cipher = ChaCha20Poly1305::new_from_slice(&key) .expect("32-byte key is valid for ChaCha20Poly1305"); let combined = base64::engine::general_purpose::STANDARD .decode(encrypted) .map_err(|_| AppError::RsaDecodeError)?; if combined.len() < 12 { return Err(AppError::RsaDecodeError); } let mut nonce_bytes = [0u8; 12]; nonce_bytes.copy_from_slice(&combined[..12]); let nonce = Nonce::from(nonce_bytes); let plaintext = cipher .decrypt(&nonce, &combined[12..]) .map_err(|_| AppError::RsaDecodeError)?; String::from_utf8(plaintext).map_err(|_| AppError::RsaDecodeError) } pub async fn auth_rsa(&self, context: &Session) -> Result { if context .get::(Self::RSA_PRIVATE_KEY) .ok() .flatten() .is_some() && context .get::(Self::RSA_PUBLIC_KEY) .ok() .flatten() .is_some() { let public_key = context .get::(Self::RSA_PUBLIC_KEY) .ok() .flatten() .expect("checked above"); return Ok(RsaResponse { public_key }); } let mut rng = rand::thread_rng(); let priv_key = RsaPrivateKey::new(&mut rng, Self::RSA_BIT_SIZE).map_err(|_| { tracing::error!("RSA key generation failed"); AppError::RsaGenerationError })?; let pub_key = RsaPublicKey::from(&priv_key); let priv_pem = priv_key .to_pkcs1_pem(Default::default()) .map_err(|_| AppError::RsaGenerationError)? .to_string(); let public_key = pub_key .to_pkcs1_pem(Default::default()) .map_err(|_| AppError::RsaGenerationError)? .to_string(); context .insert(Self::RSA_PRIVATE_KEY, self.encrypt_rsa_key(&priv_pem)?) .map_err(|_| AppError::RsaGenerationError)?; context .insert(Self::RSA_PUBLIC_KEY, public_key.clone()) .map_err(|_| AppError::RsaGenerationError)?; Ok(RsaResponse { public_key }) } pub async fn auth_rsa_decode( &self, context: &Session, data: String, ) -> Result { let encrypted_priv = context .get::(Self::RSA_PRIVATE_KEY) .map_err(|_| AppError::RsaDecodeError)? .ok_or(AppError::RsaDecodeError)?; let priv_pem = self.decrypt_rsa_key(&encrypted_priv)?; let priv_key = RsaPrivateKey::from_pkcs1_pem(&priv_pem).map_err(|_| { tracing::warn!("RSA decode failed: invalid private key"); AppError::RsaDecodeError })?; let cipher = base64::engine::general_purpose::STANDARD .decode(&data) .map_err(|_| AppError::RsaDecodeError)?; let decrypted = priv_key .decrypt(Oaep::new::(), &cipher) .map_err(|_| { tracing::warn!("RSA decrypt failed"); AppError::RsaDecodeError })?; Ok(String::from_utf8_lossy(&decrypted).to_string()) } }