#![allow(clippy::all)]
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use chrono::Utc;
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use tracing::{error, warn};
use crate::config::LoggingConfig;
use crate::error::SynqroError;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuditEvent {
SynqroInit,
UpdateCheckStarted,
UpdateAvailable,
ManifestSignatureOk,
ManifestSignatureFail,
PayloadHashOk,
PayloadHashFail,
PayloadSignatureFail,
UpdateApplied,
HealthCheckFail,
RollbackTriggered,
RollbackSuccess,
RollbackFailed,
BackupTampered,
VersionBlacklisted,
CrashReportSent,
CrashReportRateLimited,
CertPinMismatch,
TokenLoadFail,
DegradedModeEntered,
}
impl AuditEvent {
pub fn as_str(self) -> &'static str {
match self {
AuditEvent::SynqroInit => "SYNQRO_INIT",
AuditEvent::UpdateCheckStarted => "UPDATE_CHECK_STARTED",
AuditEvent::UpdateAvailable => "UPDATE_AVAILABLE",
AuditEvent::ManifestSignatureOk => "MANIFEST_SIGNATURE_OK",
AuditEvent::ManifestSignatureFail => "MANIFEST_SIGNATURE_FAIL",
AuditEvent::PayloadHashOk => "PAYLOAD_HASH_OK",
AuditEvent::PayloadHashFail => "PAYLOAD_HASH_FAIL",
AuditEvent::PayloadSignatureFail => "PAYLOAD_SIGNATURE_FAIL",
AuditEvent::UpdateApplied => "UPDATE_APPLIED",
AuditEvent::HealthCheckFail => "HEALTH_CHECK_FAIL",
AuditEvent::RollbackTriggered => "ROLLBACK_TRIGGERED",
AuditEvent::RollbackSuccess => "ROLLBACK_SUCCESS",
AuditEvent::RollbackFailed => "ROLLBACK_FAILED",
AuditEvent::BackupTampered => "BACKUP_TAMPERED",
AuditEvent::VersionBlacklisted => "VERSION_BLACKLISTED",
AuditEvent::CrashReportSent => "CRASH_REPORT_SENT",
AuditEvent::CrashReportRateLimited => "CRASH_REPORT_RATE_LIMITED",
AuditEvent::CertPinMismatch => "CERT_PIN_MISMATCH",
AuditEvent::TokenLoadFail => "TOKEN_LOAD_FAIL",
AuditEvent::DegradedModeEntered => "DEGRADED_MODE_ENTERED",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"SYNQRO_INIT" => Some(AuditEvent::SynqroInit),
"UPDATE_CHECK_STARTED" => Some(AuditEvent::UpdateCheckStarted),
"UPDATE_AVAILABLE" => Some(AuditEvent::UpdateAvailable),
"MANIFEST_SIGNATURE_OK" => Some(AuditEvent::ManifestSignatureOk),
"MANIFEST_SIGNATURE_FAIL" => Some(AuditEvent::ManifestSignatureFail),
"PAYLOAD_HASH_OK" => Some(AuditEvent::PayloadHashOk),
"PAYLOAD_HASH_FAIL" => Some(AuditEvent::PayloadHashFail),
"PAYLOAD_SIGNATURE_FAIL" => Some(AuditEvent::PayloadSignatureFail),
"UPDATE_APPLIED" => Some(AuditEvent::UpdateApplied),
"HEALTH_CHECK_FAIL" => Some(AuditEvent::HealthCheckFail),
"ROLLBACK_TRIGGERED" => Some(AuditEvent::RollbackTriggered),
"ROLLBACK_SUCCESS" => Some(AuditEvent::RollbackSuccess),
"ROLLBACK_FAILED" => Some(AuditEvent::RollbackFailed),
"BACKUP_TAMPERED" => Some(AuditEvent::BackupTampered),
"VERSION_BLACKLISTED" => Some(AuditEvent::VersionBlacklisted),
"CRASH_REPORT_SENT" => Some(AuditEvent::CrashReportSent),
"CRASH_REPORT_RATE_LIMITED" => Some(AuditEvent::CrashReportRateLimited),
"CERT_PIN_MISMATCH" => Some(AuditEvent::CertPinMismatch),
"TOKEN_LOAD_FAIL" => Some(AuditEvent::TokenLoadFail),
"DEGRADED_MODE_ENTERED" => Some(AuditEvent::DegradedModeEntered),
_ => None,
}
}
pub fn severity(self) -> AuditSeverity {
match self {
AuditEvent::SynqroInit
| AuditEvent::UpdateCheckStarted
| AuditEvent::UpdateAvailable
| AuditEvent::ManifestSignatureOk
| AuditEvent::PayloadHashOk
| AuditEvent::UpdateApplied
| AuditEvent::RollbackSuccess
| AuditEvent::CrashReportSent
| AuditEvent::CrashReportRateLimited => AuditSeverity::Info,
AuditEvent::HealthCheckFail
| AuditEvent::RollbackTriggered
| AuditEvent::VersionBlacklisted
| AuditEvent::DegradedModeEntered => AuditSeverity::Warn,
AuditEvent::ManifestSignatureFail
| AuditEvent::PayloadHashFail
| AuditEvent::PayloadSignatureFail
| AuditEvent::RollbackFailed
| AuditEvent::BackupTampered
| AuditEvent::CertPinMismatch
| AuditEvent::TokenLoadFail => AuditSeverity::Critical,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum AuditSeverity {
Info,
Warn,
Critical,
}
impl AuditSeverity {
fn as_str(self) -> &'static str {
match self {
AuditSeverity::Info => "INFO",
AuditSeverity::Warn => "WARN",
AuditSeverity::Critical => "CRITICAL",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditLogLine {
pub ts: String,
pub event: String,
pub severity: String,
pub installation_id: String,
pub data: serde_json::Value,
pub line_hmac: String,
}
#[derive(Debug)]
pub struct AuditLogger {
log_path: PathBuf,
hmac_key: [u8; 32],
installation_id: String,
file: std::sync::Mutex<File>,
}
impl AuditLogger {
pub fn new(config: &LoggingConfig, installation_id: &str) -> Result<Self, SynqroError> {
let hk = Hkdf::<Sha256>::new(Some(b"synqro-audit-v1"), installation_id.as_bytes());
let mut hmac_key = [0u8; 32];
hk.expand(b"hmac-key", &mut hmac_key)
.map_err(|e| SynqroError::Crypto(format!("HKDF expand failed: {}", e)))?;
let log_path = PathBuf::from(&config.audit_log_path);
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
SynqroError::Permission(format!("Cannot create audit log directory: {}", e))
})?;
}
let file = OpenOptions::new()
.append(true)
.create(true)
.open(&log_path)
.map_err(|e| SynqroError::Permission(format!("Cannot open audit log file: {}", e)))?;
Ok(AuditLogger {
log_path,
hmac_key,
installation_id: installation_id.to_owned(),
file: std::sync::Mutex::new(file),
})
}
pub fn log(&self, event: AuditEvent, data: serde_json::Value) -> Result<(), SynqroError> {
let ts = Utc::now().to_rfc3339();
let severity = event.severity();
let payload = serde_json::json!({
"ts": ts,
"event": event.as_str(),
"severity": severity.as_str(),
"installation_id": self.installation_id,
"data": data,
});
let canonical = canonical_json(&payload)?;
let line_hmac = self.compute_hmac(canonical.as_bytes())?;
let mut line_obj = match payload {
serde_json::Value::Object(m) => m,
_ => {
return Err(SynqroError::Internal(
"Unexpected JSON type in audit payload".into(),
))
}
};
line_obj.insert("line_hmac".to_owned(), serde_json::Value::String(line_hmac));
let mut line_bytes = serde_json::to_vec(&serde_json::Value::Object(line_obj))
.map_err(|e| SynqroError::Internal(format!("JSON serialisation failed: {}", e)))?;
line_bytes.push(b'\n');
let mut file = self
.file
.lock()
.map_err(|_| SynqroError::Internal("Audit log mutex poisoned".into()))?;
#[cfg(target_os = "linux")]
{
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
nix::fcntl::flock(fd, nix::fcntl::FlockArg::LockExclusive)
.map_err(|e| SynqroError::Permission(format!("flock failed: {}", e)))?;
}
file.write_all(&line_bytes)
.map_err(|e| SynqroError::Internal(format!("Audit log write failed: {}", e)))?;
#[cfg(target_os = "linux")]
{
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let _ = nix::fcntl::flock(fd, nix::fcntl::FlockArg::Unlock);
}
file.flush()
.map_err(|e| SynqroError::Internal(format!("Audit log flush failed: {}", e)))?;
match severity {
AuditSeverity::Critical => {
error!(event = event.as_str(), "Audit: CRITICAL event emitted");
}
AuditSeverity::Warn => {
warn!(event = event.as_str(), "Audit event emitted");
}
AuditSeverity::Info => {
tracing::info!(event = event.as_str(), "Audit event emitted");
}
}
Ok(())
}
pub fn verify_log(&self, path: &Path) -> Result<usize, SynqroError> {
let file = File::open(path).map_err(|e| {
SynqroError::Permission(format!("Cannot open log for verification: {}", e))
})?;
let reader = BufReader::new(file);
let mut valid_count: usize = 0;
for (line_no, line_result) in reader.lines().enumerate() {
let line = line_result.map_err(|e| {
SynqroError::Internal(format!(
"Read error at line {}: {}",
line_no.saturating_add(1),
e
))
})?;
if line.trim().is_empty() {
continue;
}
let log_line: AuditLogLine = serde_json::from_str(&line).map_err(|e| {
SynqroError::InvalidInput(format!(
"Line {} is not valid JSON: {}",
line_no.saturating_add(1),
e
))
})?;
let expected_payload = serde_json::json!({
"ts": log_line.ts,
"event": log_line.event,
"severity": log_line.severity,
"installation_id": log_line.installation_id,
"data": log_line.data,
});
let canonical = canonical_json(&expected_payload)?;
let expected_hmac = self.compute_hmac(canonical.as_bytes())?;
if !constant_time_eq(expected_hmac.as_bytes(), log_line.line_hmac.as_bytes()) {
return Err(SynqroError::Crypto(format!(
"HMAC mismatch on line {} — log may have been tampered with",
line_no.saturating_add(1)
)));
}
valid_count = valid_count.saturating_add(1);
}
Ok(valid_count)
}
fn compute_hmac(&self, data: &[u8]) -> Result<String, SynqroError> {
let mut mac = HmacSha256::new_from_slice(&self.hmac_key)
.map_err(|e| SynqroError::Crypto(format!("HMAC init failed: {}", e)))?;
mac.update(data);
Ok(hex::encode(mac.finalize().into_bytes()))
}
}
fn canonical_json(value: &serde_json::Value) -> Result<String, SynqroError> {
let sorted = sort_json_keys(value);
serde_json::to_string(&sorted)
.map_err(|e| SynqroError::Internal(format!("Canonical JSON failed: {}", e)))
}
fn sort_json_keys(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted: Vec<(String, serde_json::Value)> = map
.iter()
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
.collect();
sorted.sort_by(|(a, _), (b, _)| a.cmp(b));
serde_json::Value::Object(sorted.into_iter().collect())
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(sort_json_keys).collect())
}
other => other.clone(),
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}