feat: add hash-chained, append-only audit log
Introduce src/audit.rs: a SHA-256 hash-chained audit log for security-sensitive operations (key export, NIP-46 connect/revoke, signing approvals, vault lock/unlock, permission denials). - AuditEntry carries timestamp, profile npub, action, reason, success flag, error, and prev/this hash forming a tamper-evident chain from a genesis hash. - record() holds a single mutex across the entire read-compute-write- update cycle so concurrent writers cannot interleave and silently overwrite entries; appends are atomic (write-all + fsync + rename). - verify_chain() re-hashes every entry end to end. - Log lives in the app data dir with 0600 permissions. Also adds the sha2 dependency. Verified in isolation on top of HEAD: cargo test --release -> 124 passed (119 prior + 5 audit).
This commit is contained in:
parent
4038e2d32a
commit
caed722b06
4 changed files with 491 additions and 1 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -17,5 +17,6 @@ base64 = "0.22"
|
|||
getrandom = "0.2"
|
||||
zeroize = "1"
|
||||
rpassword = "7"
|
||||
sha2 = "0.10"
|
||||
async-trait = "0.1"
|
||||
keyring = "4.2"
|
||||
|
|
|
|||
476
src/audit.rs
Normal file
476
src/audit.rs
Normal file
|
|
@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
impl AuditLog {
|
||||
/// Open or create the audit log, returning the last hash for chaining.
|
||||
pub fn open() -> Result<Self, AppError> {
|
||||
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<String, AppError> {
|
||||
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<String>,
|
||||
) -> Result<AuditEntry, AppError> {
|
||||
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<bool, AppError> {
|
||||
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<String> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod app;
|
||||
pub mod audit;
|
||||
pub mod bunker;
|
||||
pub mod crypto;
|
||||
pub mod errors;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue