diff --git a/Cargo.lock b/Cargo.lock index 0f28161..8dd71c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1177,6 +1177,7 @@ dependencies = [ "rpassword", "serde", "serde_json", + "sha2 0.10.9", "tokio", "uuid", "zeroize", @@ -1841,7 +1842,7 @@ dependencies = [ "num", "once_cell", "serde", - "sha2", + "sha2 0.11.0", "zbus", ] @@ -1933,6 +1934,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index 888ddfd..6212a43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,5 +17,6 @@ base64 = "0.22" getrandom = "0.2" zeroize = "1" rpassword = "7" +sha2 = "0.10" async-trait = "0.1" keyring = "4.2" diff --git a/src/audit.rs b/src/audit.rs new file mode 100644 index 0000000..1e7512f --- /dev/null +++ b/src/audit.rs @@ -0,0 +1,476 @@ +use std::fs; +use std::fs::OpenOptions; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::errors::AppError; + +/// File name for the append-only audit log. +const AUDIT_LOG_FILE: &str = "audit.log"; + +/// Algorithm used for hash-chaining. +/// Hash algorithm used for chain entries (informational only). +#[allow(dead_code)] +const HASH_ALGORITHM: &str = "sha256"; + +/// Canonical audit log entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEntry { + /// Unix timestamp in seconds. + pub timestamp: u64, + /// The profile npub this action relates to. + pub profile_npub: String, + /// Action type. + pub action: AuditAction, + /// Human-readable reason for the action (required for exports). + pub reason: String, + /// Whether the action succeeded. + pub success: bool, + /// Error message if failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Hash of the previous entry for chain integrity. + pub prev_hash: String, + /// Hash of this entry (timestamp|profile|action|reason|success|error|prev_hash). + pub this_hash: String, +} + +/// Actions that are audited. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditAction { + /// Private key export requested. + KeyExport, + /// NIP-46 connection created. + ConnectionCreated, + /// NIP-46 connection revoked. + ConnectionRevoked, + /// Signing request approved/rejected. + SignRequest, + /// Encrypt/decrypt request. + Nip44Request, + /// Vault unlocked. + VaultUnlocked, + /// Vault locked. + VaultLocked, + /// NIP-46 operation denied by permission check. + ConnectionPermissionDenied, +} + +/// The audit log writer. +pub struct AuditLog { + path: PathBuf, + last_hash: Mutex, +} + +impl AuditLog { + /// Open or create the audit log, returning the last hash for chaining. + pub fn open() -> Result { + let path = crate::vault::data_dir().join(AUDIT_LOG_FILE); + let last_hash = Self::compute_last_hash(&path)?; + Ok(Self { + path, + last_hash: Mutex::new(last_hash), + }) + } + + /// Compute the hash of the last entry in the log, or genesis hash if empty. + fn compute_last_hash(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(Self::genesis_hash()); + } + let content = + fs::read_to_string(path).map_err(|e| AppError::io("Could not read audit log", e))?; + let lines: Vec<&str> = content.lines().collect(); + if lines.is_empty() { + return Ok(Self::genesis_hash()); + } + // Parse the last line as JSON and extract its this_hash + let last_line = lines.last().unwrap(); + let entry: AuditEntry = serde_json::from_str(last_line) + .map_err(|e| AppError::vault_malformed(format!("Audit log corrupted: {e}")))?; + Ok(entry.this_hash) + } + + /// Genesis hash for empty log. + fn genesis_hash() -> String { + "0".repeat(64) + } + + /// Write an audit entry atomically. Fails closed if write fails. + /// + /// The mutex is held across the entire check-write-update cycle to prevent + /// concurrent threads from reading the same `prev_hash`, which would cause + /// one entry to silently overwrite another on rename. + pub fn write_entry(&self, entry: &AuditEntry) -> Result<(), AppError> { + let mut last = self.last_hash.lock().expect("audit mutex poisoned"); + + // Verify chain integrity before appending + if entry.prev_hash != *last { + return Err(AppError::storage( + "Audit chain integrity check failed: prev_hash mismatch", + )); + } + + // Serialize canonically: sorted keys, no whitespace, deterministic + let json = serde_json::to_string(entry) + .map_err(|e| AppError::json("Could not serialize audit entry", e))?; + + // Atomic append: read existing, write all to temp, sync, rename + let tmp_path = self.path.with_extension("log.tmp"); + { + // Read existing content (empty file is fine) + let existing = fs::read_to_string(&self.path).unwrap_or_default(); + + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp_path) + .map_err(|e| AppError::io("Could not open audit log temp file", e))?; + file.write_all(existing.as_bytes()) + .map_err(|e| AppError::io("Could not write existing audit log content", e))?; + file.write_all(json.as_bytes()) + .map_err(|e| AppError::io("Could not write audit log temp file", e))?; + file.write_all(b"\n") + .map_err(|e| AppError::io("Could not write audit log newline", e))?; + file.sync_all() + .map_err(|e| AppError::io("Could not sync audit log temp file", e))?; + } + + // Rename temp to actual (atomic on POSIX) + fs::rename(&tmp_path, &self.path) + .map_err(|e| AppError::io("Could not finalize audit log", e))?; + + // Update last hash — still under the same lock + *last = entry.this_hash.clone(); + + Ok(()) + } + + /// Build and write a new entry, returning the entry for the caller. + /// + /// The entire read-compute-write-update cycle is under a single mutex + /// acquisition to prevent concurrent writers from interleaving. + pub fn record( + &self, + profile_npub: &str, + action: AuditAction, + reason: &str, + success: bool, + error: Option, + ) -> Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| AppError::internal(format!("System clock error: {e}")))? + .as_secs(); + + let mut last = self.last_hash.lock().expect("audit mutex poisoned"); + let prev_hash = last.clone(); + + // Compute this hash from canonical fields + let this_hash = Self::compute_hash(&AuditEntry { + timestamp, + profile_npub: profile_npub.to_string(), + action, + reason: reason.to_string(), + success, + error: error.clone(), + prev_hash: prev_hash.clone(), + this_hash: String::new(), // placeholder + }); + + let entry = AuditEntry { + timestamp, + profile_npub: profile_npub.to_string(), + action, + reason: reason.to_string(), + success, + error, + prev_hash, + this_hash, + }; + + // Serialize canonically + let json = serde_json::to_string(&entry) + .map_err(|e| AppError::json("Could not serialize audit entry", e))?; + + // Atomic append: read existing, write all to temp, sync, rename + let tmp_path = self.path.with_extension("log.tmp"); + { + let existing = fs::read_to_string(&self.path).unwrap_or_default(); + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp_path) + .map_err(|e| AppError::io("Could not open audit log temp file", e))?; + file.write_all(existing.as_bytes()) + .map_err(|e| AppError::io("Could not write existing audit log content", e))?; + file.write_all(json.as_bytes()) + .map_err(|e| AppError::io("Could not write audit log temp file", e))?; + file.write_all(b"\n") + .map_err(|e| AppError::io("Could not write audit log newline", e))?; + file.sync_all() + .map_err(|e| AppError::io("Could not sync audit log temp file", e))?; + } + + // Rename temp to actual (atomic on POSIX) + fs::rename(&tmp_path, &self.path) + .map_err(|e| AppError::io("Could not finalize audit log", e))?; + + // Update last hash — still under the same lock + *last = entry.this_hash.clone(); + + Ok(entry) + } + + /// Canonical hash: timestamp|profile_npub|action|reason|success|error|prev_hash + /// All fields are JSON-encoded to avoid delimiter ambiguity. + fn compute_hash(entry: &AuditEntry) -> String { + let mut hasher = Sha256::new(); + // Use JSON values for canonical representation + let timestamp_json = serde_json::to_string(&entry.timestamp).unwrap(); + let profile_json = serde_json::to_string(&entry.profile_npub).unwrap(); + let action_json = serde_json::to_string(&entry.action).unwrap(); + let reason_json = serde_json::to_string(&entry.reason).unwrap(); + let success_json = serde_json::to_string(&entry.success).unwrap(); + let error_json = serde_json::to_string(&entry.error).unwrap(); + let prev_hash_json = serde_json::to_string(&entry.prev_hash).unwrap(); + + hasher.update(timestamp_json.as_bytes()); + hasher.update(b"|"); + hasher.update(profile_json.as_bytes()); + hasher.update(b"|"); + hasher.update(action_json.as_bytes()); + hasher.update(b"|"); + hasher.update(reason_json.as_bytes()); + hasher.update(b"|"); + hasher.update(success_json.as_bytes()); + hasher.update(b"|"); + hasher.update(error_json.as_bytes()); + hasher.update(b"|"); + hasher.update(prev_hash_json.as_bytes()); + + B64.encode(hasher.finalize()) + } + + /// Verify the entire chain from genesis to end. + pub fn verify_chain(&self) -> Result { + if !self.path.exists() { + return Ok(true); + } + let content = fs::read_to_string(&self.path) + .map_err(|e| AppError::io("Could not read audit log for verification", e))?; + let mut expected_prev = Self::genesis_hash(); + for line in content.lines() { + let entry: AuditEntry = match serde_json::from_str(line) { + Ok(e) => e, + Err(_) => return Ok(false), // corrupted line = invalid chain + }; + if entry.prev_hash != expected_prev { + return Ok(false); + } + let computed = Self::compute_hash(&entry); + if computed != entry.this_hash { + return Ok(false); + } + expected_prev = entry.this_hash; + } + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::sync::atomic::{AtomicU32, Ordering}; + + static COUNTER: AtomicU32 = AtomicU32::new(0); + + fn temp_audit_dir() -> PathBuf { + let dir = env::temp_dir().join(format!( + "keynectr-audit-test-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst) + )); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn audit_log_chain_works() { + let dir = temp_audit_dir(); + let log_path = dir.join(AUDIT_LOG_FILE); + // Manually create an AuditLog pointing to our temp dir + let audit = AuditLog { + path: log_path.clone(), + last_hash: Mutex::new(AuditLog::genesis_hash()), + }; + + // Write first entry + let e1 = audit + .record( + "npub1alice", + AuditAction::KeyExport, + "migration backup", + true, + None, + ) + .unwrap(); + assert_eq!(e1.prev_hash, AuditLog::genesis_hash()); + assert!(AuditLog::compute_hash(&e1) == e1.this_hash); + + // Write second entry + let e2 = audit + .record( + "npub1bob", + AuditAction::KeyExport, + "key rotation", + true, + None, + ) + .unwrap(); + assert_eq!(e2.prev_hash, e1.this_hash); + assert!(AuditLog::compute_hash(&e2) == e2.this_hash); + + // Verify chain + assert!(audit.verify_chain().unwrap()); + + // Read back and verify + let content = fs::read_to_string(&log_path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 2); + let parsed1: AuditEntry = serde_json::from_str(lines[0]).unwrap(); + let parsed2: AuditEntry = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(parsed1.this_hash, e1.this_hash); + assert_eq!(parsed2.this_hash, e2.this_hash); + } + + #[test] + fn audit_log_rejects_tampered_chain() { + let dir = temp_audit_dir(); + let log_path = dir.join(AUDIT_LOG_FILE); + let audit = AuditLog { + path: log_path.clone(), + last_hash: Mutex::new(AuditLog::genesis_hash()), + }; + + let e1 = audit + .record("npub1alice", AuditAction::KeyExport, "reason", true, None) + .unwrap(); + // Tamper: modify the file directly + let mut content = fs::read_to_string(&log_path).unwrap(); + content = content.replace(&e1.reason, "tampered"); + fs::write(&log_path, content).unwrap(); + + // New AuditLog should detect mismatch + let audit2 = AuditLog { + path: log_path.clone(), + last_hash: Mutex::new(AuditLog::genesis_hash()), + }; + assert!(!audit2.verify_chain().unwrap()); + } + + #[test] + fn audit_entry_serialization_deterministic() { + let entry = AuditEntry { + timestamp: 1_700_000_000, + profile_npub: "npub1test".to_string(), + action: AuditAction::KeyExport, + reason: "test reason".to_string(), + success: true, + error: None, + prev_hash: "0".repeat(64), + this_hash: "1".repeat(64), + }; + let json1 = serde_json::to_string(&entry).unwrap(); + let json2 = serde_json::to_string(&entry).unwrap(); + assert_eq!(json1, json2); + } + + #[test] + fn audit_log_fails_on_write_error() { + // Use a path we can't write to + let audit = AuditLog { + path: PathBuf::from("/root/cannot_write.log"), + last_hash: Mutex::new(AuditLog::genesis_hash()), + }; + let entry = AuditEntry { + timestamp: 1, + profile_npub: "npub1test".to_string(), + action: AuditAction::KeyExport, + reason: "test".to_string(), + success: true, + error: None, + prev_hash: AuditLog::genesis_hash(), + this_hash: "x".repeat(64), + }; + assert!(audit.write_entry(&entry).is_err()); + } + + #[test] + fn concurrent_audit_writes_are_serialized() { + use std::sync::Arc; + use std::thread; + + let dir = temp_audit_dir(); + let log_path = dir.join(AUDIT_LOG_FILE); + let audit = Arc::new(AuditLog { + path: log_path.clone(), + last_hash: Mutex::new(AuditLog::genesis_hash()), + }); + + let num_writers = 8; + let mut handles = vec![]; + + for i in 0..num_writers { + let audit_clone = Arc::clone(&audit); + handles.push(thread::spawn(move || { + audit_clone + .record( + &format!("npub1writer{i}"), + AuditAction::KeyExport, + &format!("concurrent write {i}"), + true, + None, + ) + .unwrap(); + })); + } + + for h in handles { + h.join().unwrap(); + } + + // Verify chain: all entries present and chain valid + let content = fs::read_to_string(&log_path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), num_writers); + + // Verify chain integrity + assert!(audit.verify_chain().unwrap()); + + // Verify no duplicate npubs (each writer wrote a unique entry) + let npubs: Vec = lines + .iter() + .filter_map(|line| { + let entry: AuditEntry = serde_json::from_str(line).ok()?; + Some(entry.profile_npub) + }) + .collect(); + let unique: std::collections::HashSet<_> = npubs.iter().collect(); + assert_eq!(unique.len(), num_writers); + } +} diff --git a/src/lib.rs b/src/lib.rs index f28ccf5..45c78f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod audit; pub mod bunker; pub mod crypto; pub mod errors;