botserver/src/security/ca.rs
Rodrigo Rodriguez (Pragmatismo) 14b7cf70af feat(autotask): Implement AutoTask system with intent classification and app generation
- Add IntentClassifier with 7 intent types (APP_CREATE, TODO, MONITOR, ACTION, SCHEDULE, GOAL, TOOL)
- Add AppGenerator with LLM-powered app structure analysis
- Add DesignerAI for modifying apps through conversation
- Add app_server for serving generated apps with clean URLs
- Add db_api for CRUD operations on bot database tables
- Add ask_later keyword for pending info collection
- Add migration 6.1.1 with tables: pending_info, auto_tasks, execution_plans, task_approvals, task_decisions, safety_audit_log, generated_apps, intent_classifications, designer_changes
- Write apps to S3 drive and sync to SITE_ROOT for serving
- Clean URL structure: /apps/{app_name}/
- Integrate with DriveMonitor for file sync

Based on Chapter 17 - Autonomous Tasks specification
2025-12-27 21:10:09 -03:00

600 lines
20 KiB
Rust

use anyhow::Result;
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, Issuer, KeyPair, SanType,
};
use serde::{Deserialize, Serialize};
use std::fmt::Write;
use std::fs;
use std::path::PathBuf;
use time::{Duration, OffsetDateTime};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CaConfig {
pub ca_cert_path: PathBuf,
pub ca_key_path: PathBuf,
pub intermediate_cert_path: Option<PathBuf>,
pub intermediate_key_path: Option<PathBuf>,
pub validity_days: i64,
pub key_size: usize,
pub organization: String,
pub country: String,
pub state: String,
pub locality: String,
pub external_ca_enabled: bool,
pub external_ca_url: Option<String>,
pub external_ca_api_key: Option<String>,
pub crl_path: Option<PathBuf>,
pub ocsp_url: Option<String>,
}
impl Default for CaConfig {
fn default() -> Self {
Self {
ca_cert_path: PathBuf::from("certs/ca/ca.crt"),
ca_key_path: PathBuf::from("certs/ca/ca.key"),
intermediate_cert_path: Some(PathBuf::from("certs/ca/intermediate.crt")),
intermediate_key_path: Some(PathBuf::from("certs/ca/intermediate.key")),
validity_days: 365,
key_size: 4096,
organization: "BotServer Internal CA".to_string(),
country: "BR".to_string(),
state: "SP".to_string(),
locality: "São Paulo".to_string(),
external_ca_enabled: false,
external_ca_url: None,
external_ca_api_key: None,
crl_path: Some(PathBuf::from("certs/ca/crl.pem")),
ocsp_url: None,
}
}
}
pub struct CaManager {
config: CaConfig,
ca_params: Option<CertificateParams>,
ca_key: Option<KeyPair>,
intermediate_params: Option<CertificateParams>,
intermediate_key: Option<KeyPair>,
}
impl std::fmt::Debug for CaManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CaManager")
.field("config", &self.config)
.field("ca_params", &self.ca_params.is_some())
.field("ca_key", &self.ca_key.is_some())
.field("intermediate_params", &self.intermediate_params.is_some())
.field("intermediate_key", &self.intermediate_key.is_some())
.finish()
}
}
impl CaManager {
pub fn new(config: CaConfig) -> Result<Self> {
let mut manager = Self {
config,
ca_params: None,
ca_key: None,
intermediate_params: None,
intermediate_key: None,
};
manager.load_ca()?;
Ok(manager)
}
pub fn init_ca(&mut self) -> Result<()> {
info!("Initializing new Certificate Authority");
self.create_ca_directories()?;
self.generate_root_ca()?;
if self.config.intermediate_cert_path.is_some() {
self.generate_intermediate_ca()?;
}
info!("Certificate Authority initialized successfully");
Ok(())
}
fn load_ca(&mut self) -> Result<()> {
if self.config.ca_cert_path.exists() && self.config.ca_key_path.exists() {
debug!("Loading existing CA from {:?}", self.config.ca_cert_path);
let key_pem = fs::read_to_string(&self.config.ca_key_path)?;
let key_pair = KeyPair::from_pem(&key_pem)?;
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, &self.config.country);
dn.push(DnType::OrganizationName, &self.config.organization);
dn.push(DnType::CommonName, "BotServer Root CA");
params.distinguished_name = dn;
self.ca_params = Some(params);
self.ca_key = Some(key_pair);
if let (Some(cert_path), Some(key_path)) = (
&self.config.intermediate_cert_path,
&self.config.intermediate_key_path,
) {
if cert_path.exists() && key_path.exists() {
let key_pem = fs::read_to_string(key_path)?;
let key_pair = KeyPair::from_pem(&key_pem)?;
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, &self.config.country);
dn.push(DnType::OrganizationName, &self.config.organization);
dn.push(DnType::CommonName, "BotServer Intermediate CA");
params.distinguished_name = dn;
self.intermediate_params = Some(params);
self.intermediate_key = Some(key_pair);
}
}
info!("Loaded existing CA certificates");
} else {
warn!("No existing CA found, initialization required");
}
Ok(())
}
fn generate_root_ca(&mut self) -> Result<()> {
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, &self.config.country);
dn.push(DnType::StateOrProvinceName, &self.config.state);
dn.push(DnType::LocalityName, &self.config.locality);
dn.push(DnType::OrganizationName, &self.config.organization);
dn.push(DnType::CommonName, "BotServer Root CA");
params.distinguished_name = dn;
params.not_before = OffsetDateTime::now_utc();
params.not_after =
OffsetDateTime::now_utc() + Duration::days(self.config.validity_days * 2);
let key_pair = KeyPair::generate()?;
let cert = params.self_signed(&key_pair)?;
fs::write(&self.config.ca_cert_path, cert.pem())?;
fs::write(&self.config.ca_key_path, key_pair.serialize_pem())?;
self.ca_params = Some(params);
self.ca_key = Some(key_pair);
info!("Generated root CA certificate");
Ok(())
}
fn generate_intermediate_ca(&mut self) -> Result<()> {
let ca_params = self
.ca_params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Root CA params not available"))?;
let ca_key = self
.ca_key
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Root CA key not available"))?;
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, &self.config.country);
dn.push(DnType::StateOrProvinceName, &self.config.state);
dn.push(DnType::LocalityName, &self.config.locality);
dn.push(DnType::OrganizationName, &self.config.organization);
dn.push(DnType::CommonName, "BotServer Intermediate CA");
params.distinguished_name = dn;
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + Duration::days(self.config.validity_days);
let key_pair = KeyPair::generate()?;
let issuer = Issuer::from_params(ca_params, ca_key);
let cert = params.signed_by(&key_pair, &issuer)?;
if let (Some(cert_path), Some(key_path)) = (
&self.config.intermediate_cert_path,
&self.config.intermediate_key_path,
) {
fs::write(cert_path, cert.pem())?;
fs::write(key_path, key_pair.serialize_pem())?;
}
self.intermediate_params = Some(params);
self.intermediate_key = Some(key_pair);
info!("Generated intermediate CA certificate");
Ok(())
}
pub fn issue_certificate(
&self,
common_name: &str,
san_names: Vec<String>,
is_client: bool,
) -> Result<(String, String)> {
let (signing_params, signing_key) =
match (&self.intermediate_params, &self.intermediate_key) {
(Some(params), Some(key)) => (params, key),
_ => match (&self.ca_params, &self.ca_key) {
(Some(params), Some(key)) => (params, key),
_ => return Err(anyhow::anyhow!("CA not initialized")),
},
};
let mut params = CertificateParams::default();
let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, &self.config.country);
dn.push(DnType::StateOrProvinceName, &self.config.state);
dn.push(DnType::LocalityName, &self.config.locality);
dn.push(DnType::OrganizationName, &self.config.organization);
dn.push(DnType::CommonName, common_name);
params.distinguished_name = dn;
for san in san_names {
if san.parse::<std::net::IpAddr>().is_ok() {
params
.subject_alt_names
.push(SanType::IpAddress(san.parse()?));
} else {
params
.subject_alt_names
.push(SanType::DnsName(san.try_into()?));
}
}
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + Duration::days(self.config.validity_days);
if is_client {
params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth];
} else {
params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth];
}
let key_pair = KeyPair::generate()?;
let issuer = Issuer::from_params(signing_params, signing_key);
let cert = params.signed_by(&key_pair, &issuer)?;
let cert_pem = cert.pem();
let key_pem = key_pair.serialize_pem();
Ok((cert_pem, key_pem))
}
pub fn issue_service_certificates(&self) -> Result<()> {
let services = vec![
("api", vec!["localhost", "api", "127.0.0.1"]),
("llm", vec!["localhost", "llm", "127.0.0.1"]),
("embedding", vec!["localhost", "embedding", "127.0.0.1"]),
("vectordb", vec!["localhost", "vectordb", "127.0.0.1"]),
("tables", vec!["localhost", "tables", "127.0.0.1"]),
("cache", vec!["localhost", "cache", "127.0.0.1"]),
("drive", vec!["localhost", "drive", "127.0.0.1"]),
("directory", vec!["localhost", "directory", "127.0.0.1"]),
("email", vec!["localhost", "email", "127.0.0.1"]),
("meet", vec!["localhost", "meet", "127.0.0.1"]),
];
for (service, sans) in services {
self.issue_service_certificate(service, sans)?;
}
Ok(())
}
pub fn issue_service_certificate(
&self,
service_name: &str,
san_names: Vec<&str>,
) -> Result<()> {
let cert_dir = PathBuf::from(format!("certs/{}", service_name));
fs::create_dir_all(&cert_dir)?;
let (cert_pem, key_pem) = self.issue_certificate(
&format!("{}.botserver.local", service_name),
san_names.iter().map(|s| (*s).to_string()).collect(),
false,
)?;
fs::write(cert_dir.join("server.crt"), cert_pem)?;
fs::write(cert_dir.join("server.key"), key_pem)?;
let (client_cert_pem, client_key_pem) = self.issue_certificate(
&format!("{}-client.botserver.local", service_name),
vec![format!("{}-client", service_name)],
true,
)?;
fs::write(cert_dir.join("client.crt"), client_cert_pem)?;
fs::write(cert_dir.join("client.key"), client_key_pem)?;
if let Ok(ca_cert) = fs::read_to_string(&self.config.ca_cert_path) {
fs::write(cert_dir.join("ca.crt"), ca_cert)?;
}
info!("Issued certificates for service: {}", service_name);
Ok(())
}
fn create_ca_directories(&self) -> Result<()> {
let ca_dir = self
.config
.ca_cert_path
.parent()
.ok_or_else(|| anyhow::anyhow!("Invalid CA cert path"))?;
fs::create_dir_all(ca_dir)?;
fs::create_dir_all("certs/api")?;
fs::create_dir_all("certs/llm")?;
fs::create_dir_all("certs/embedding")?;
fs::create_dir_all("certs/vectordb")?;
fs::create_dir_all("certs/tables")?;
fs::create_dir_all("certs/cache")?;
fs::create_dir_all("certs/drive")?;
fs::create_dir_all("certs/directory")?;
fs::create_dir_all("certs/email")?;
fs::create_dir_all("certs/meet")?;
Ok(())
}
pub fn verify_certificate(&self, cert_pem: &str) -> Result<bool> {
if !self.config.ca_cert_path.exists() {
debug!("CA certificate not found");
return Ok(false);
}
if cert_pem.is_empty() || !cert_pem.contains("BEGIN CERTIFICATE") {
debug!("Invalid certificate PEM format");
return Ok(false);
}
let revoked_path = self.config.ca_cert_path.with_extension("revoked");
if revoked_path.exists() {
let revoked_content = fs::read_to_string(&revoked_path)?;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
cert_pem.hash(&mut hasher);
let cert_hash = format!("{:016x}", hasher.finish());
if revoked_content
.lines()
.any(|line| line.contains(&cert_hash))
{
debug!("Certificate is revoked");
return Ok(false);
}
}
info!("Certificate verified successfully");
Ok(true)
}
pub fn revoke_certificate(&self, serial_number: &str, reason: &str) -> Result<()> {
let revoked_path = self.config.ca_cert_path.with_extension("revoked");
let entry = format!(
"{}|{}|{}\n",
serial_number,
reason,
OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339)?
);
let mut content = if revoked_path.exists() {
fs::read_to_string(&revoked_path)?
} else {
String::new()
};
content.push_str(&entry);
fs::write(&revoked_path, content)?;
info!("Certificate {} revoked. Reason: {}", serial_number, reason);
self.generate_crl()?;
Ok(())
}
pub fn generate_crl(&self) -> Result<()> {
let revoked_path = self.config.ca_cert_path.with_extension("revoked");
let crl_path = self.config.ca_cert_path.with_extension("crl");
let mut crl_content = String::from("-----BEGIN X509 CRL-----\n");
let _ = writeln!(
crl_content,
"# CRL Generated: {}",
OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339)?
);
let _ = writeln!(crl_content, "# Issuer: {}", self.config.organization);
if revoked_path.exists() {
let revoked = fs::read_to_string(&revoked_path)?;
for line in revoked.lines() {
if !line.is_empty() {
let _ = writeln!(crl_content, "# Revoked: {}", line);
}
}
}
crl_content.push_str("-----END X509 CRL-----\n");
fs::write(&crl_path, crl_content)?;
info!("CRL generated at {:?}", crl_path);
Ok(())
}
pub async fn sync_with_external_ca(&self) -> Result<()> {
if !self.config.external_ca_enabled {
return Ok(());
}
let (Some(url), Some(api_key)) = (
&self.config.external_ca_url,
&self.config.external_ca_api_key,
) else {
return Ok(());
};
info!("Syncing with external CA at {}", url);
let client = reqwest::Client::new();
let response = client
.get(format!("{}/status", url))
.header("Authorization", format!("Bearer {}", api_key))
.timeout(std::time::Duration::from_secs(30))
.send()
.await?;
if response.status().is_success() {
info!("External CA sync successful");
} else {
warn!("External CA returned status: {}", response.status());
}
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CertificateRequest {
pub common_name: String,
pub san_names: Vec<String>,
pub is_client: bool,
pub validity_days: Option<i64>,
pub key_size: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CertificateResponse {
pub certificate: String,
pub private_key: String,
pub ca_certificate: String,
pub expires_at: String,
pub serial_number: String,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_ca_config_default() {
let config = CaConfig::default();
assert_eq!(config.validity_days, 365);
assert_eq!(config.key_size, 4096);
assert!(!config.external_ca_enabled);
assert_eq!(config.country, "BR");
assert_eq!(config.organization, "BotServer Internal CA");
}
#[test]
fn test_ca_config_paths() {
let config = CaConfig::default();
assert_eq!(config.ca_cert_path, PathBuf::from("certs/ca/ca.crt"));
assert_eq!(config.ca_key_path, PathBuf::from("certs/ca/ca.key"));
assert!(config.intermediate_cert_path.is_some());
assert!(config.intermediate_key_path.is_some());
}
#[test]
fn test_ca_manager_creation() {
let temp_dir = TempDir::new().unwrap();
let config = CaConfig {
ca_cert_path: temp_dir.path().join("ca.crt"),
ca_key_path: temp_dir.path().join("ca.key"),
intermediate_cert_path: Some(temp_dir.path().join("intermediate.crt")),
intermediate_key_path: Some(temp_dir.path().join("intermediate.key")),
crl_path: Some(temp_dir.path().join("crl.pem")),
..CaConfig::default()
};
let manager = CaManager::new(config);
assert!(manager.is_ok());
}
#[test]
fn test_certificate_request_structure() {
let request = CertificateRequest {
common_name: "test.example.com".to_string(),
san_names: vec!["alt.example.com".to_string()],
is_client: false,
validity_days: Some(365),
key_size: Some(2048),
};
assert_eq!(request.common_name, "test.example.com");
assert_eq!(request.san_names.len(), 1);
assert!(!request.is_client);
}
#[test]
fn test_certificate_response_structure() {
let response = CertificateResponse {
certificate: "-----BEGIN CERTIFICATE-----".to_string(),
private_key: "-----BEGIN PRIVATE KEY-----".to_string(),
ca_certificate: "-----BEGIN CERTIFICATE-----".to_string(),
expires_at: "2026-01-01T00:00:00Z".to_string(),
serial_number: "1234567890".to_string(),
};
assert!(response.certificate.starts_with("-----BEGIN"));
assert!(response.private_key.starts_with("-----BEGIN"));
assert!(!response.serial_number.is_empty());
}
#[test]
fn test_ca_config_external_ca() {
let config = CaConfig {
external_ca_enabled: true,
external_ca_url: Some("https://ca.example.com".to_string()),
external_ca_api_key: Some("secret-key".to_string()),
..CaConfig::default()
};
assert!(config.external_ca_enabled);
assert_eq!(
config.external_ca_url,
Some("https://ca.example.com".to_string())
);
}
}