use anyhow::{Context, Result};
use chacha20poly1305::{
ChaCha20Poly1305, Key, Nonce,
aead::{Aead, AeadCore, KeyInit},
};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use tokio::sync::RwLock;
use tracing::{debug, info, instrument, warn};
use zeroize::Zeroizing;
const MAX_LOG_SIZE_BYTES: u64 = 10 * 1024 * 1024;
const MAX_LOG_AGE_DAYS: i64 = 60;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuditEventType {
Login,
Logout,
FailedLogin,
IdentitySwitch,
DeviceChange,
Recovery,
SessionRefresh,
SessionExpired,
}
impl std::fmt::Display for AuditEventType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Login => write!(f, "login"),
Self::Logout => write!(f, "logout"),
Self::FailedLogin => write!(f, "failed_login"),
Self::IdentitySwitch => write!(f, "identity_switch"),
Self::DeviceChange => write!(f, "device_change"),
Self::Recovery => write!(f, "recovery"),
Self::SessionRefresh => write!(f, "session_refresh"),
Self::SessionExpired => write!(f, "session_expired"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub id: String,
pub timestamp: DateTime<Utc>,
pub event_type: AuditEventType,
pub success: bool,
pub identity_redacted: String,
pub device_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
impl AuditEvent {
pub fn new(
event_type: AuditEventType,
success: bool,
four_words: &str,
device_fingerprint: &str,
) -> Self {
Self {
id: uuid::Uuid::now_v7().to_string(),
timestamp: Utc::now(),
event_type,
success,
identity_redacted: redact_identity(four_words),
device_fingerprint: device_fingerprint.to_string(),
metadata: None,
}
}
pub fn with_metadata(
event_type: AuditEventType,
success: bool,
four_words: &str,
device_fingerprint: &str,
metadata: serde_json::Value,
) -> Self {
let mut event = Self::new(event_type, success, four_words, device_fingerprint);
event.metadata = Some(metadata);
event
}
}
fn redact_identity(four_words: &str) -> String {
let words: Vec<&str> = four_words.split('-').collect();
if words.len() >= 2 {
format!("{}-{}-••••", words[0], words[1])
} else {
"••••-••••-••••".to_string()
}
}
pub struct AuditLog {
log_dir: PathBuf,
encryption_key: Zeroizing<Vec<u8>>,
current_log: RwLock<PathBuf>,
}
impl AuditLog {
#[instrument(skip(device_key))]
pub async fn new(log_dir: PathBuf, device_key: Zeroizing<Vec<u8>>) -> Result<Self> {
fs::create_dir_all(&log_dir)
.with_context(|| format!("Failed to create audit log directory: {:?}", log_dir))?;
if device_key.len() != 32 {
return Err(anyhow::anyhow!(
"Invalid device key length: expected 32, got {}",
device_key.len()
));
}
let current_log = Self::find_or_create_current_log(&log_dir)?;
info!("Audit log initialized at {:?}", log_dir);
Ok(Self {
log_dir,
encryption_key: device_key,
current_log: RwLock::new(current_log),
})
}
fn find_or_create_current_log(log_dir: &Path) -> Result<PathBuf> {
let mut log_files: Vec<_> = fs::read_dir(log_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry.file_name().to_string_lossy().starts_with("audit_")
&& entry.file_name().to_string_lossy().ends_with(".enc")
})
.collect();
log_files.sort_by_key(|entry| std::cmp::Reverse(entry.file_name()));
if let Some(latest) = log_files.first() {
let metadata = latest.metadata()?;
if metadata.len() < MAX_LOG_SIZE_BYTES {
return Ok(latest.path());
}
}
Self::create_new_log_file(log_dir)
}
fn create_new_log_file(log_dir: &Path) -> Result<PathBuf> {
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let filename = format!("audit_{}.enc", timestamp);
let path = log_dir.join(filename);
File::create(&path).with_context(|| format!("Failed to create audit log: {:?}", path))?;
debug!("Created new audit log file: {:?}", path);
Ok(path)
}
#[instrument(skip(self), fields(event_type = %event.event_type))]
pub async fn log(&self, event: AuditEvent) -> Result<()> {
let event_json =
serde_json::to_string(&event).with_context(|| "Failed to serialize audit event")?;
let encrypted = self
.encrypt_line(&event_json)
.await
.with_context(|| "Failed to encrypt audit event")?;
let log_path = self.get_or_rotate_log().await?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.with_context(|| format!("Failed to open audit log: {:?}", log_path))?;
let mut writer = BufWriter::new(file);
writeln!(writer, "{}", encrypted).with_context(|| "Failed to write audit event")?;
writer
.flush()
.with_context(|| "Failed to flush audit log")?;
debug!("Logged audit event: {} ({})", event.event_type, event.id);
Ok(())
}
async fn get_or_rotate_log(&self) -> Result<PathBuf> {
let mut current_lock = self.current_log.write().await;
let current = current_lock.clone();
let metadata = fs::metadata(¤t);
let needs_rotation = match metadata {
Ok(m) => m.len() >= MAX_LOG_SIZE_BYTES,
Err(_) => true, };
if needs_rotation {
let new_log = Self::create_new_log_file(&self.log_dir)?;
*current_lock = new_log.clone();
return Ok(new_log);
}
Ok(current)
}
async fn encrypt_line(&self, plaintext: &str) -> Result<String> {
let key = Key::from_slice(&self.encryption_key);
let cipher = ChaCha20Poly1305::new(key);
let nonce = ChaCha20Poly1305::generate_nonce(&mut rand::thread_rng());
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| anyhow::anyhow!("Encryption failed: {:?}", e))?;
let mut combined = nonce.to_vec();
combined.extend(ciphertext);
Ok(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
combined,
))
}
fn decrypt_line(&self, encrypted: &str) -> Result<String> {
let combined =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encrypted.trim())
.with_context(|| "Failed to decode audit line")?;
if combined.len() < 12 {
return Err(anyhow::anyhow!("Invalid encrypted line: too short"));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let key = Key::from_slice(&self.encryption_key);
let cipher = ChaCha20Poly1305::new(key);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| anyhow::anyhow!("Decryption failed: {:?}", e))?;
String::from_utf8(plaintext).with_context(|| "Decrypted data is not valid UTF-8")
}
#[instrument(skip(self))]
pub async fn read_recent(
&self,
limit: usize,
event_filter: Option<Vec<AuditEventType>>,
) -> Result<Vec<AuditEvent>> {
let mut events = Vec::new();
let mut log_files: Vec<_> = fs::read_dir(&self.log_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry.file_name().to_string_lossy().starts_with("audit_")
&& entry.file_name().to_string_lossy().ends_with(".enc")
})
.collect();
log_files.sort_by_key(|entry| std::cmp::Reverse(entry.file_name()));
for entry in log_files {
if events.len() >= limit {
break;
}
let file = File::open(entry.path())?;
let reader = BufReader::new(file);
let mut file_events: Vec<AuditEvent> = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(l) if !l.is_empty() => l,
_ => continue,
};
match self.decrypt_line(&line) {
Ok(decrypted) => {
if let Ok(event) = serde_json::from_str::<AuditEvent>(&decrypted) {
if let Some(ref filter) = event_filter
&& !filter.contains(&event.event_type)
{
continue;
}
file_events.push(event);
}
}
Err(e) => {
warn!("Failed to decrypt audit line: {}", e);
continue;
}
}
}
file_events.reverse();
events.extend(file_events);
}
events.truncate(limit);
Ok(events)
}
#[instrument(skip(self))]
pub async fn cleanup_old_logs(&self) -> Result<usize> {
let cutoff = Utc::now() - Duration::days(MAX_LOG_AGE_DAYS);
let mut removed = 0;
let entries = fs::read_dir(&self.log_dir)?;
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !filename.starts_with("audit_") || !filename.ends_with(".enc") {
continue;
}
if let Some(timestamp_str) = filename
.strip_prefix("audit_")
.and_then(|s| s.strip_suffix(".enc"))
&& let Ok(file_time) =
chrono::NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d_%H%M%S")
{
let file_datetime = file_time.and_utc();
if file_datetime < cutoff {
if let Err(e) = fs::remove_file(&path) {
warn!("Failed to remove old audit log {:?}: {}", path, e);
} else {
info!("Removed old audit log: {:?}", path);
removed += 1;
}
}
}
}
Ok(removed)
}
#[instrument(skip(self))]
pub async fn export_range(
&self,
start: DateTime<Utc>,
end: DateTime<Utc>,
event_filter: Option<Vec<AuditEventType>>,
) -> Result<Vec<AuditEvent>> {
let mut events = Vec::new();
let log_files: Vec<_> = fs::read_dir(&self.log_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry.file_name().to_string_lossy().starts_with("audit_")
&& entry.file_name().to_string_lossy().ends_with(".enc")
})
.collect();
for entry in log_files {
let file = File::open(entry.path())?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = match line {
Ok(l) if !l.is_empty() => l,
_ => continue,
};
match self.decrypt_line(&line) {
Ok(decrypted) => {
if let Ok(event) = serde_json::from_str::<AuditEvent>(&decrypted) {
if event.timestamp < start || event.timestamp > end {
continue;
}
if let Some(ref filter) = event_filter
&& !filter.contains(&event.event_type)
{
continue;
}
events.push(event);
}
}
Err(e) => {
warn!("Failed to decrypt audit line during export: {}", e);
continue;
}
}
}
}
events.sort_by_key(|event| event.timestamp);
Ok(events)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn create_test_log() -> (AuditLog, TempDir) {
let temp_dir = TempDir::new().unwrap();
let key = Zeroizing::new(vec![0u8; 32]); let log = AuditLog::new(temp_dir.path().to_path_buf(), key)
.await
.unwrap();
(log, temp_dir)
}
#[tokio::test]
async fn test_create_audit_log() {
let (log, _temp_dir) = create_test_log().await;
let current = log.current_log.read().await;
assert!(current.exists());
}
#[tokio::test]
async fn test_log_and_read_event() {
let (log, _temp_dir) = create_test_log().await;
let event = AuditEvent::new(
AuditEventType::Login,
true,
"ocean-forest-moon-star",
"device123",
);
log.log(event.clone()).await.unwrap();
let events = log.read_recent(10, None).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, AuditEventType::Login);
assert_eq!(events[0].identity_redacted, "ocean-forest-••••");
assert!(events[0].success);
}
#[tokio::test]
async fn test_event_filter() {
let (log, _temp_dir) = create_test_log().await;
log.log(AuditEvent::new(
AuditEventType::Login,
true,
"test-identity-one-two",
"device1",
))
.await
.unwrap();
log.log(AuditEvent::new(
AuditEventType::FailedLogin,
false,
"test-identity-one-two",
"device1",
))
.await
.unwrap();
log.log(AuditEvent::new(
AuditEventType::Logout,
true,
"test-identity-one-two",
"device1",
))
.await
.unwrap();
let events = log
.read_recent(10, Some(vec![AuditEventType::FailedLogin]))
.await
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, AuditEventType::FailedLogin);
}
#[tokio::test]
async fn test_redact_identity() {
assert_eq!(
redact_identity("ocean-forest-moon-star"),
"ocean-forest-••••"
);
assert_eq!(redact_identity("alpha-beta-gamma-delta"), "alpha-beta-••••");
assert_eq!(redact_identity("short"), "••••-••••-••••");
}
#[tokio::test]
async fn test_event_with_metadata() {
let (log, _temp_dir) = create_test_log().await;
let metadata = serde_json::json!({
"ip_address": "192.168.1.1",
"user_agent": "Communitas/1.0"
});
let event = AuditEvent::with_metadata(
AuditEventType::DeviceChange,
true,
"test-words-one-two",
"device456",
metadata,
);
log.log(event).await.unwrap();
let events = log.read_recent(10, None).await.unwrap();
assert_eq!(events.len(), 1);
assert!(events[0].metadata.is_some());
}
#[tokio::test]
async fn test_invalid_key_length() {
let temp_dir = TempDir::new().unwrap();
let key = Zeroizing::new(vec![0u8; 16]); let result = AuditLog::new(temp_dir.path().to_path_buf(), key).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_decryption_with_wrong_key_fails() {
let temp_dir = TempDir::new().unwrap();
let key1 = Zeroizing::new(vec![0u8; 32]);
let log1 = AuditLog::new(temp_dir.path().to_path_buf(), key1)
.await
.unwrap();
let event = AuditEvent::new(
AuditEventType::Login,
true,
"ocean-forest-moon-star",
"device123",
);
log1.log(event).await.unwrap();
let key2 = Zeroizing::new(vec![1u8; 32]); let log2 = AuditLog::new(temp_dir.path().to_path_buf(), key2)
.await
.unwrap();
let events = log2.read_recent(10, None).await.unwrap();
assert!(
events.is_empty(),
"Events should be empty when using wrong key"
);
}
#[tokio::test]
async fn test_decrypt_line_with_wrong_key_returns_error() {
let temp_dir = TempDir::new().unwrap();
let key1 = Zeroizing::new(vec![0u8; 32]);
let log1 = AuditLog::new(temp_dir.path().to_path_buf(), key1)
.await
.unwrap();
let encrypted = log1.encrypt_line("secret message").await.unwrap();
let key2 = Zeroizing::new(vec![1u8; 32]);
let log2 = AuditLog::new(temp_dir.path().to_path_buf(), key2)
.await
.unwrap();
let result = log2.decrypt_line(&encrypted);
assert!(result.is_err(), "Decryption should fail with wrong key");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Decryption failed"),
"Error should indicate decryption failure: {}",
err_msg
);
}
}