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:
Avi 2026-09-03 09:21:53 -05:00
commit caed722b06
4 changed files with 491 additions and 1 deletions

14
Cargo.lock generated
View file

@ -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"

View file

@ -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
View 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);
}
}

View file

@ -1,4 +1,5 @@
pub mod app;
pub mod audit;
pub mod bunker;
pub mod crypto;
pub mod errors;