147 lines
5.6 KiB
Rust
147 lines
5.6 KiB
Rust
use crate::AppService;
|
|
use crate::error::AppError;
|
|
use base64::Engine;
|
|
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, aead::Aead};
|
|
use hkdf::Hkdf;
|
|
use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, EncodeRsaPublicKey};
|
|
use rsa::{Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey};
|
|
use serde::{Deserialize, Serialize};
|
|
use session::Session;
|
|
use sha2::Sha256;
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct RsaResponse {
|
|
pub public_key: String,
|
|
}
|
|
|
|
impl AppService {
|
|
pub const RSA_PRIVATE_KEY: &'static str = "rsa:private";
|
|
pub const RSA_PUBLIC_KEY: &'static str = "rsa:public";
|
|
const RSA_BIT_SIZE: usize = 2048;
|
|
|
|
/// Derive a ChaCha20-Poly1305 encryption key from APP_SESSION_SECRET via HKDF-SHA256.
|
|
fn derive_rsa_encryption_key(&self) -> [u8; 32] {
|
|
let secret = self
|
|
.config
|
|
.env
|
|
.get("APP_SESSION_SECRET")
|
|
.map(|s| s.as_str())
|
|
.expect("APP_SESSION_SECRET must be set in production. Do not use fallback keys.");
|
|
let hk = Hkdf::<Sha256>::new(Some(b"rsa-session-encryption"), secret.as_bytes());
|
|
let mut okm = [0u8; 32];
|
|
hk.expand(b"rsa-private-key-aead", &mut okm)
|
|
.expect("HKDF expand within hash length");
|
|
okm
|
|
}
|
|
|
|
/// Encrypt a plaintext string with ChaCha20-Poly1305. Returns base64(nonce+ciphertext+tag).
|
|
fn encrypt_rsa_key(&self, plaintext: &str) -> Result<String, AppError> {
|
|
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 = chacha20poly1305::aead::generic_array::GenericArray::from_slice(&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))
|
|
}
|
|
|
|
/// Decrypt a base64(nonce+ciphertext+tag) string with ChaCha20-Poly1305.
|
|
fn decrypt_rsa_key(&self, encrypted: &str) -> Result<String, AppError> {
|
|
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 nonce =
|
|
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&combined[..12]);
|
|
let plaintext = cipher
|
|
.decrypt(nonce, &combined[12..])
|
|
.map_err(|_| AppError::RsaDecodeError)?;
|
|
Ok(String::from_utf8(plaintext).map_err(|_| AppError::RsaDecodeError)?)
|
|
}
|
|
|
|
pub async fn auth_rsa(&self, context: &Session) -> Result<RsaResponse, AppError> {
|
|
if context
|
|
.get::<String>(Self::RSA_PRIVATE_KEY)
|
|
.ok()
|
|
.flatten()
|
|
.is_some()
|
|
&& context
|
|
.get::<String>(Self::RSA_PUBLIC_KEY)
|
|
.ok()
|
|
.flatten()
|
|
.is_some()
|
|
{
|
|
let pub_pem = context
|
|
.get::<String>(Self::RSA_PUBLIC_KEY)
|
|
.ok()
|
|
.flatten()
|
|
.expect("checked above");
|
|
return Ok(RsaResponse {
|
|
public_key: pub_pem,
|
|
});
|
|
}
|
|
|
|
let mut rng = rsa::rand_core::OsRng;
|
|
let Ok(priv_key) = RsaPrivateKey::new(&mut rng, Self::RSA_BIT_SIZE) else {
|
|
tracing::error!("RSA key generation failed");
|
|
return Err(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 pub_pem = pub_key
|
|
.to_pkcs1_pem(Default::default())
|
|
.map_err(|_| AppError::RsaGenerationError)?
|
|
.to_string();
|
|
|
|
let encrypted_priv = self.encrypt_rsa_key(&priv_pem)?;
|
|
|
|
context
|
|
.insert(Self::RSA_PRIVATE_KEY, encrypted_priv)
|
|
.map_err(|_| AppError::RsaGenerationError)?;
|
|
context
|
|
.insert(Self::RSA_PUBLIC_KEY, pub_pem.clone())
|
|
.map_err(|_| AppError::RsaGenerationError)?;
|
|
Ok(RsaResponse {
|
|
public_key: pub_pem,
|
|
})
|
|
}
|
|
|
|
pub async fn auth_rsa_decode(
|
|
&self,
|
|
context: &Session,
|
|
data: String,
|
|
) -> Result<String, AppError> {
|
|
let encrypted_priv = context
|
|
.get::<String>(Self::RSA_PRIVATE_KEY)
|
|
.map_err(|_| AppError::RsaDecodeError)?
|
|
.ok_or(AppError::RsaDecodeError)?;
|
|
|
|
let priv_pem = self.decrypt_rsa_key(&encrypted_priv)?;
|
|
|
|
let Ok(priv_key) = RsaPrivateKey::from_pkcs1_pem(&priv_pem) else {
|
|
tracing::warn!(ip = ?context.ip_address(), "RSA decode failed: invalid private key");
|
|
return Err(AppError::RsaDecodeError);
|
|
};
|
|
let cipher = base64::engine::general_purpose::STANDARD
|
|
.decode(&data)
|
|
.map_err(|_| AppError::RsaDecodeError)?;
|
|
let Ok(decrypted) = priv_key.decrypt(Pkcs1v15Encrypt, &cipher) else {
|
|
tracing::warn!(ip = ?context.ip_address(), "RSA decrypt failed");
|
|
return Err(AppError::RsaDecodeError);
|
|
};
|
|
Ok(String::from_utf8_lossy(&decrypted).to_string())
|
|
}
|
|
}
|