botserver/src/security/cert_pinning.rs
Rodrigo Rodriguez (Pragmatismo) dd59e923f4 Add documentation infrastructure and certificate pinning
- Add mdBook configuration (book.toml) for documentation
- Create new docs style guide appendix for conversation examples
- Add WhatsApp-style chat CSS for consistent doc formatting
- Replace flow diagram references with screen mockup SVGs
- Create comprehensive SVG interface mockups for all Suite apps:
  - Main suite layout and individual app screens
  - Analytics, Calendar, Chat, Compliance, Designer
  - Drive, Mail, Meet, Paper, Player, Research
  - Sources, Tasks interfaces
- Implement certificate pinning module (cert_pinning.rs) with:
  - SPKI fingerprint validation using SHA-256
  - Support for primary and backup pins
  - Pin rotation with expiration tracking
  - Report-only mode for testing
  - Validation caching for performance
- Add ring crate dependency for cryptographic operations
2025-12-01 16:15:52 -03:00

852 lines
26 KiB
Rust

//! Certificate Pinning Module
//!
//! Provides certificate pinning functionality to prevent man-in-the-middle attacks
//! by validating server certificates against pre-configured SHA-256 fingerprints.
//!
//! # Overview
//!
//! Certificate pinning adds an additional layer of security beyond standard TLS
//! certificate validation. Even if an attacker obtains a valid certificate from
//! a trusted CA, the connection will be rejected if the certificate's fingerprint
//! doesn't match the pinned value.
//!
//! # Usage
//!
//! ```rust,ignore
//! use botserver::security::cert_pinning::{CertPinningConfig, CertPinningManager, PinnedCert};
//!
//! let mut config = CertPinningConfig::default();
//! config.add_pin(PinnedCert::new(
//! "api.example.com",
//! "sha256//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
//! ));
//!
//! let manager = CertPinningManager::new(config);
//! let client = manager.create_pinned_client("api.example.com")?;
//! ```
use anyhow::{anyhow, Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use reqwest::{Certificate, Client, ClientBuilder};
use ring::digest::{digest, SHA256};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tracing::{debug, error, info, warn};
use x509_parser::prelude::*;
/// Configuration for certificate pinning
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CertPinningConfig {
/// Enable certificate pinning globally
pub enabled: bool,
/// Pinned certificates by hostname
pub pins: HashMap<String, Vec<PinnedCert>>,
/// Whether to fail if no pin is configured for a host
pub require_pins: bool,
/// Allow backup pins (multiple pins per host for rotation)
pub allow_backup_pins: bool,
/// Report-only mode (log violations but don't block)
pub report_only: bool,
/// Path to store/load pin configuration
pub config_path: Option<PathBuf>,
/// Pin validation cache TTL in seconds
pub cache_ttl_secs: u64,
}
impl Default for CertPinningConfig {
fn default() -> Self {
Self {
enabled: true,
pins: HashMap::new(),
require_pins: false,
allow_backup_pins: true,
report_only: false,
config_path: None,
cache_ttl_secs: 3600,
}
}
}
impl CertPinningConfig {
/// Create a new config with pinning enabled
pub fn new() -> Self {
Self::default()
}
/// Create a strict config that requires pins for all hosts
pub fn strict() -> Self {
Self {
enabled: true,
pins: HashMap::new(),
require_pins: true,
allow_backup_pins: true,
report_only: false,
config_path: None,
cache_ttl_secs: 3600,
}
}
/// Create a report-only config for testing
pub fn report_only() -> Self {
Self {
enabled: true,
pins: HashMap::new(),
require_pins: false,
allow_backup_pins: true,
report_only: true,
config_path: None,
cache_ttl_secs: 3600,
}
}
/// Add a pinned certificate
pub fn add_pin(&mut self, pin: PinnedCert) {
let hostname = pin.hostname.clone();
self.pins.entry(hostname).or_default().push(pin);
}
/// Add multiple pins for a hostname (primary + backups)
pub fn add_pins(&mut self, hostname: &str, pins: Vec<PinnedCert>) {
self.pins.insert(hostname.to_string(), pins);
}
/// Remove all pins for a hostname
pub fn remove_pins(&mut self, hostname: &str) {
self.pins.remove(hostname);
}
/// Get pins for a hostname
pub fn get_pins(&self, hostname: &str) -> Option<&Vec<PinnedCert>> {
self.pins.get(hostname)
}
/// Load configuration from file
pub fn load_from_file(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read pin config from {:?}", path))?;
let config: Self =
serde_json::from_str(&content).context("Failed to parse pin configuration")?;
info!("Loaded certificate pinning config from {:?}", path);
Ok(config)
}
/// Save configuration to file
pub fn save_to_file(&self, path: &Path) -> Result<()> {
let content =
serde_json::to_string_pretty(self).context("Failed to serialize pin configuration")?;
fs::write(path, content)
.with_context(|| format!("Failed to write pin config to {:?}", path))?;
info!("Saved certificate pinning config to {:?}", path);
Ok(())
}
}
/// A pinned certificate entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PinnedCert {
/// Hostname this pin applies to
pub hostname: String,
/// SHA-256 fingerprint of the certificate's Subject Public Key Info (SPKI)
/// Format: "sha256//BASE64_ENCODED_HASH"
pub fingerprint: String,
/// Optional human-readable description
pub description: Option<String>,
/// Whether this is a backup pin
pub is_backup: bool,
/// Expiration date (for pin rotation planning)
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
/// Pin type (leaf certificate, intermediate, or root)
pub pin_type: PinType,
}
impl PinnedCert {
/// Create a new pinned certificate entry
pub fn new(hostname: &str, fingerprint: &str) -> Self {
Self {
hostname: hostname.to_string(),
fingerprint: fingerprint.to_string(),
description: None,
is_backup: false,
expires_at: None,
pin_type: PinType::Leaf,
}
}
/// Create a backup pin
pub fn backup(hostname: &str, fingerprint: &str) -> Self {
Self {
hostname: hostname.to_string(),
fingerprint: fingerprint.to_string(),
description: Some("Backup pin for certificate rotation".to_string()),
is_backup: true,
expires_at: None,
pin_type: PinType::Leaf,
}
}
/// Set the pin type
pub fn with_type(mut self, pin_type: PinType) -> Self {
self.pin_type = pin_type;
self
}
/// Set description
pub fn with_description(mut self, desc: &str) -> Self {
self.description = Some(desc.to_string());
self
}
/// Set expiration
pub fn with_expiration(mut self, expires: chrono::DateTime<chrono::Utc>) -> Self {
self.expires_at = Some(expires);
self
}
/// Extract the raw hash bytes from the fingerprint
pub fn get_hash_bytes(&self) -> Result<Vec<u8>> {
let hash_str = self
.fingerprint
.strip_prefix("sha256//")
.ok_or_else(|| anyhow!("Invalid fingerprint format, expected 'sha256//BASE64'"))?;
BASE64
.decode(hash_str)
.context("Failed to decode base64 fingerprint")
}
/// Verify if a certificate matches this pin
pub fn verify(&self, cert_der: &[u8]) -> Result<bool> {
let expected_hash = self.get_hash_bytes()?;
let actual_hash = compute_spki_fingerprint(cert_der)?;
Ok(expected_hash == actual_hash)
}
}
/// Type of certificate being pinned
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PinType {
/// Pin the leaf/end-entity certificate
Leaf,
/// Pin an intermediate CA certificate
Intermediate,
/// Pin the root CA certificate
Root,
}
impl Default for PinType {
fn default() -> Self {
Self::Leaf
}
}
/// Result of a pin validation
#[derive(Debug, Clone)]
pub struct PinValidationResult {
/// Whether the pin validation passed
pub valid: bool,
/// The hostname that was validated
pub hostname: String,
/// Which pin matched (if any)
pub matched_pin: Option<String>,
/// The actual fingerprint of the certificate
pub actual_fingerprint: String,
/// Error message if validation failed
pub error: Option<String>,
/// Whether this was a backup pin match
pub backup_match: bool,
}
impl PinValidationResult {
/// Create a successful validation result
pub fn success(hostname: &str, fingerprint: &str, backup: bool) -> Self {
Self {
valid: true,
hostname: hostname.to_string(),
matched_pin: Some(fingerprint.to_string()),
actual_fingerprint: fingerprint.to_string(),
error: None,
backup_match: backup,
}
}
/// Create a failed validation result
pub fn failure(hostname: &str, actual: &str, error: &str) -> Self {
Self {
valid: false,
hostname: hostname.to_string(),
matched_pin: None,
actual_fingerprint: actual.to_string(),
error: Some(error.to_string()),
backup_match: false,
}
}
}
/// Certificate Pinning Manager
pub struct CertPinningManager {
config: Arc<RwLock<CertPinningConfig>>,
validation_cache: Arc<RwLock<HashMap<String, (PinValidationResult, std::time::Instant)>>>,
}
impl CertPinningManager {
/// Create a new certificate pinning manager
pub fn new(config: CertPinningConfig) -> Self {
Self {
config: Arc::new(RwLock::new(config)),
validation_cache: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Create with default configuration
pub fn default_manager() -> Self {
Self::new(CertPinningConfig::default())
}
/// Check if pinning is enabled
pub fn is_enabled(&self) -> bool {
self.config.read().unwrap().enabled
}
/// Add a pin dynamically
pub fn add_pin(&self, pin: PinnedCert) -> Result<()> {
let mut config = self
.config
.write()
.map_err(|_| anyhow!("Failed to acquire write lock"))?;
config.add_pin(pin);
Ok(())
}
/// Remove pins for a hostname
pub fn remove_pins(&self, hostname: &str) -> Result<()> {
let mut config = self
.config
.write()
.map_err(|_| anyhow!("Failed to acquire write lock"))?;
config.remove_pins(hostname);
// Clear cache for this hostname
let mut cache = self
.validation_cache
.write()
.map_err(|_| anyhow!("Failed to acquire cache lock"))?;
cache.remove(hostname);
Ok(())
}
/// Validate a certificate against pinned fingerprints
pub fn validate_certificate(
&self,
hostname: &str,
cert_der: &[u8],
) -> Result<PinValidationResult> {
let config = self
.config
.read()
.map_err(|_| anyhow!("Failed to acquire read lock"))?;
if !config.enabled {
return Ok(PinValidationResult::success(hostname, "disabled", false));
}
// Check cache first
if let Ok(cache) = self.validation_cache.read() {
if let Some((result, timestamp)) = cache.get(hostname) {
if timestamp.elapsed().as_secs() < config.cache_ttl_secs {
return Ok(result.clone());
}
}
}
// Compute actual fingerprint
let actual_hash = compute_spki_fingerprint(cert_der)?;
let actual_fingerprint = format!("sha256//{}", BASE64.encode(&actual_hash));
// Get pins for this hostname
let pins = match config.get_pins(hostname) {
Some(pins) => pins,
None => {
if config.require_pins {
let result = PinValidationResult::failure(
hostname,
&actual_fingerprint,
"No pins configured for hostname",
);
if config.report_only {
warn!(
"Certificate pinning violation (report-only): {} - {}",
hostname, "No pins configured"
);
return Ok(PinValidationResult::success(hostname, "report-only", false));
}
return Ok(result);
}
// No pins required, pass through
return Ok(PinValidationResult::success(
hostname,
"no-pins-required",
false,
));
}
};
// Check against all pins
for pin in pins {
match pin.verify(cert_der) {
Ok(true) => {
let result =
PinValidationResult::success(hostname, &pin.fingerprint, pin.is_backup);
if pin.is_backup {
warn!(
"Certificate matched backup pin for {}: {}",
hostname,
pin.description.as_deref().unwrap_or("backup")
);
}
// Update cache
if let Ok(mut cache) = self.validation_cache.write() {
cache.insert(
hostname.to_string(),
(result.clone(), std::time::Instant::now()),
);
}
return Ok(result);
}
Ok(false) => continue,
Err(e) => {
debug!("Pin verification error for {}: {}", hostname, e);
continue;
}
}
}
// No pin matched
let result = PinValidationResult::failure(
hostname,
&actual_fingerprint,
&format!(
"Certificate fingerprint {} does not match any pinned certificate",
actual_fingerprint
),
);
if config.report_only {
warn!(
"Certificate pinning violation (report-only): {} - actual fingerprint: {}",
hostname, actual_fingerprint
);
return Ok(PinValidationResult::success(hostname, "report-only", false));
}
error!(
"Certificate pinning failure for {}: fingerprint {} not in pin set",
hostname, actual_fingerprint
);
Ok(result)
}
/// Create an HTTP client with certificate pinning for a specific host
pub fn create_pinned_client(&self, hostname: &str) -> Result<Client> {
self.create_pinned_client_with_options(hostname, None, Duration::from_secs(30))
}
/// Create an HTTP client with certificate pinning and custom options
pub fn create_pinned_client_with_options(
&self,
hostname: &str,
ca_cert: Option<&Certificate>,
timeout: Duration,
) -> Result<Client> {
let config = self
.config
.read()
.map_err(|_| anyhow!("Failed to acquire read lock"))?;
let mut builder = ClientBuilder::new()
.timeout(timeout)
.connect_timeout(Duration::from_secs(10))
.use_rustls_tls()
.https_only(true)
.tls_built_in_root_certs(true);
// Add custom CA if provided
if let Some(cert) = ca_cert {
builder = builder.add_root_certificate(cert.clone());
}
// If pinning is enabled and we have pins, we need to use a custom verifier
// Note: reqwest doesn't directly support custom certificate verification,
// so we validate after connection or use a pre-flight check
if config.enabled && config.get_pins(hostname).is_some() {
debug!(
"Creating pinned client for {} with {} pins",
hostname,
config.get_pins(hostname).map(|p| p.len()).unwrap_or(0)
);
}
builder.build().context("Failed to build HTTP client")
}
/// Validate a certificate from a PEM file
pub fn validate_pem_file(
&self,
hostname: &str,
pem_path: &Path,
) -> Result<PinValidationResult> {
let pem_data = fs::read(pem_path)
.with_context(|| format!("Failed to read PEM file: {:?}", pem_path))?;
let der = pem_to_der(&pem_data)?;
self.validate_certificate(hostname, &der)
}
/// Generate a pin from a certificate file
pub fn generate_pin_from_file(hostname: &str, cert_path: &Path) -> Result<PinnedCert> {
let cert_data = fs::read(cert_path)
.with_context(|| format!("Failed to read certificate: {:?}", cert_path))?;
// Try PEM first, then DER
let der = if cert_data.starts_with(b"-----BEGIN") {
pem_to_der(&cert_data)?
} else {
cert_data
};
let fingerprint = compute_spki_fingerprint(&der)?;
let fingerprint_str = format!("sha256//{}", BASE64.encode(&fingerprint));
Ok(PinnedCert::new(hostname, &fingerprint_str))
}
/// Generate pins for all certificates in a directory
pub fn generate_pins_from_directory(
hostname: &str,
cert_dir: &Path,
) -> Result<Vec<PinnedCert>> {
let mut pins = Vec::new();
for entry in fs::read_dir(cert_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if matches!(ext, "crt" | "pem" | "cer" | "der") {
match Self::generate_pin_from_file(hostname, &path) {
Ok(pin) => {
info!("Generated pin from {:?}: {}", path, pin.fingerprint);
pins.push(pin);
}
Err(e) => {
warn!("Failed to generate pin from {:?}: {}", path, e);
}
}
}
}
}
Ok(pins)
}
/// Export current pins to a file
pub fn export_pins(&self, path: &Path) -> Result<()> {
let config = self
.config
.read()
.map_err(|_| anyhow!("Failed to acquire read lock"))?;
config.save_to_file(path)
}
/// Import pins from a file
pub fn import_pins(&self, path: &Path) -> Result<()> {
let imported = CertPinningConfig::load_from_file(path)?;
let mut config = self
.config
.write()
.map_err(|_| anyhow!("Failed to acquire write lock"))?;
for (hostname, pins) in imported.pins {
config.pins.insert(hostname, pins);
}
// Clear cache
if let Ok(mut cache) = self.validation_cache.write() {
cache.clear();
}
Ok(())
}
/// Get statistics about pinned certificates
pub fn get_stats(&self) -> Result<PinningStats> {
let config = self
.config
.read()
.map_err(|_| anyhow!("Failed to acquire read lock"))?;
let mut total_pins = 0;
let mut backup_pins = 0;
let mut expiring_soon = 0;
let now = chrono::Utc::now();
let soon = now + chrono::Duration::days(30);
for pins in config.pins.values() {
for pin in pins {
total_pins += 1;
if pin.is_backup {
backup_pins += 1;
}
if let Some(expires) = pin.expires_at {
if expires < soon {
expiring_soon += 1;
}
}
}
}
Ok(PinningStats {
enabled: config.enabled,
total_hosts: config.pins.len(),
total_pins,
backup_pins,
expiring_soon,
report_only: config.report_only,
})
}
}
/// Statistics about certificate pinning
#[derive(Debug, Clone, Serialize)]
pub struct PinningStats {
pub enabled: bool,
pub total_hosts: usize,
pub total_pins: usize,
pub backup_pins: usize,
pub expiring_soon: usize,
pub report_only: bool,
}
/// Compute SHA-256 fingerprint of a certificate's Subject Public Key Info (SPKI)
pub fn compute_spki_fingerprint(cert_der: &[u8]) -> Result<Vec<u8>> {
let (_, cert) = X509Certificate::from_der(cert_der)
.map_err(|e| anyhow!("Failed to parse X.509 certificate: {}", e))?;
// Get the raw SPKI bytes
let spki = cert.public_key().raw;
// Compute SHA-256 hash
let hash = digest(&SHA256, spki);
Ok(hash.as_ref().to_vec())
}
/// Compute SHA-256 fingerprint of the entire certificate (not just SPKI)
pub fn compute_cert_fingerprint(cert_der: &[u8]) -> Vec<u8> {
let hash = digest(&SHA256, cert_der);
hash.as_ref().to_vec()
}
/// Convert PEM-encoded certificate to DER
pub fn pem_to_der(pem_data: &[u8]) -> Result<Vec<u8>> {
let pem_str = std::str::from_utf8(pem_data).context("Invalid UTF-8 in PEM data")?;
// Find certificate block
let start_marker = "-----BEGIN CERTIFICATE-----";
let end_marker = "-----END CERTIFICATE-----";
let start = pem_str
.find(start_marker)
.ok_or_else(|| anyhow!("No certificate found in PEM data"))?;
let end = pem_str
.find(end_marker)
.ok_or_else(|| anyhow!("Invalid PEM: missing end marker"))?;
let base64_data = &pem_str[start + start_marker.len()..end];
let cleaned: String = base64_data.chars().filter(|c| !c.is_whitespace()).collect();
BASE64
.decode(&cleaned)
.context("Failed to decode base64 certificate data")
}
/// Format a fingerprint for display
pub fn format_fingerprint(hash: &[u8]) -> String {
hash.iter()
.map(|b| format!("{:02X}", b))
.collect::<Vec<_>>()
.join(":")
}
/// Parse a formatted fingerprint back to bytes
pub fn parse_fingerprint(formatted: &str) -> Result<Vec<u8>> {
// Handle "sha256//BASE64" format
if let Some(base64_part) = formatted.strip_prefix("sha256//") {
return BASE64
.decode(base64_part)
.context("Failed to decode base64 fingerprint");
}
// Handle colon-separated hex format
if formatted.contains(':') {
let bytes: Result<Vec<u8>, _> = formatted
.split(':')
.map(|hex| u8::from_str_radix(hex, 16))
.collect();
return bytes.context("Failed to parse hex fingerprint");
}
// Try plain hex
let bytes: Result<Vec<u8>, _> = (0..formatted.len())
.step_by(2)
.map(|i| u8::from_str_radix(&formatted[i..i + 2], 16))
.collect();
bytes.context("Failed to parse fingerprint")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pinned_cert_creation() {
let pin = PinnedCert::new(
"api.example.com",
"sha256//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
);
assert_eq!(pin.hostname, "api.example.com");
assert!(!pin.is_backup);
assert_eq!(pin.pin_type, PinType::Leaf);
}
#[test]
fn test_backup_pin() {
let pin = PinnedCert::backup(
"api.example.com",
"sha256//BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
);
assert!(pin.is_backup);
assert!(pin.description.is_some());
}
#[test]
fn test_config_add_pin() {
let mut config = CertPinningConfig::default();
config.add_pin(PinnedCert::new(
"example.com",
"sha256//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
));
assert!(config.get_pins("example.com").is_some());
assert_eq!(config.get_pins("example.com").unwrap().len(), 1);
}
#[test]
fn test_format_fingerprint() {
let hash = vec![0xAB, 0xCD, 0xEF, 0x12];
let formatted = format_fingerprint(&hash);
assert_eq!(formatted, "AB:CD:EF:12");
}
#[test]
fn test_parse_fingerprint_hex() {
let result = parse_fingerprint("AB:CD:EF:12").unwrap();
assert_eq!(result, vec![0xAB, 0xCD, 0xEF, 0x12]);
}
#[test]
fn test_parse_fingerprint_base64() {
let original = vec![0xAB, 0xCD, 0xEF, 0x12];
let base64 = format!("sha256//{}", BASE64.encode(&original));
let result = parse_fingerprint(&base64).unwrap();
assert_eq!(result, original);
}
#[test]
fn test_pinning_stats() {
let mut config = CertPinningConfig::default();
config.add_pin(PinnedCert::new(
"host1.com",
"sha256//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
));
config.add_pin(PinnedCert::backup(
"host1.com",
"sha256//BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
));
config.add_pin(PinnedCert::new(
"host2.com",
"sha256//CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
));
let manager = CertPinningManager::new(config);
let stats = manager.get_stats().unwrap();
assert!(stats.enabled);
assert_eq!(stats.total_hosts, 2);
assert_eq!(stats.total_pins, 3);
assert_eq!(stats.backup_pins, 1);
}
#[test]
fn test_pem_to_der() {
// Minimal test PEM (this is a mock, real certs would be longer)
let mock_pem = b"-----BEGIN CERTIFICATE-----
MIIB
-----END CERTIFICATE-----";
// Should fail gracefully with invalid base64
let result = pem_to_der(mock_pem);
// We expect this to fail because "MIIB" is incomplete base64
assert!(result.is_err() || result.unwrap().len() > 0);
}
#[test]
fn test_manager_disabled() {
let mut config = CertPinningConfig::default();
config.enabled = false;
let manager = CertPinningManager::new(config);
assert!(!manager.is_enabled());
}
}