Skip to main content

kipuka/audit/
mod.rs

1//! Structured audit trail (NIAP CA PP FAU family).
2//!
3//! All EST and administrative operations that must be logged for Common
4//! Criteria evaluation call [`record`].  The function inserts one row into
5//! `audit_events`, enforces the overflow policy (FAU_STG.4), and maintains
6//! the rolling security-violation counter for the alarm response (FAU_ARP.1).
7//!
8//! # NIAP CA PP requirements implemented
9//!
10//! | SFR | Requirement | Implementation |
11//! |-----|-------------|----------------|
12//! | FAU_GEN.1 | Audit record generation | [`AuditEventType`] taxonomy covers all required events |
13//! | FAU_STG.1(1) | Audit trail protection | Append-only + tamper-evident hash chain ([`verify_chain`]) |
14//! | FAU_STG.4 | Audit storage exhaustion | `OverflowAction::Halt` rejects EST operations |
15//! | FAU_ARP.1 | Security alarm | Alarm after N consecutive violations |
16
17use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
18
19use hmac::digest::KeyInit;
20use hmac::{Hmac, Mac};
21use sha2::{Digest, Sha256};
22
23use crate::error::KipukaError;
24
25/// HMAC-SHA256, used for the keyed audit hash chain when `signed = true`.
26type HmacSha256 = Hmac<Sha256>;
27
28/// ASCII Record Separator (0x1e).  Delimits fields in the canonical hash
29/// preimage so that field values cannot be shifted across boundaries to
30/// produce a colliding preimage (e.g. `"a" + "bc"` vs `"ab" + "c"`).
31const RS: u8 = 0x1e;
32
33/// Every auditable operation the server can perform.
34///
35/// NIAP CA PP FAU_GEN.1: the following categories of events MUST be
36/// auditable:
37///
38/// - Certificate lifecycle (enrollment, re-enrollment, rejection, revocation)
39/// - Key management (generation, destruction, HSM operations)
40/// - OTP lifecycle (creation, usage, expiration, revocation)
41/// - Authentication events (success, failure)
42/// - Administrative operations (login, logout, config changes)
43/// - CA health status changes
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum AuditEventType {
46    // ── CA lifecycle ─────────────────────────────────────────────────────
47    /// CA started or restarted.
48    CaStart,
49    /// CA stopped (graceful shutdown).
50    CaStop,
51    /// CA health status changed (degraded, recovered).
52    CaHealthChange,
53
54    // ── Certificate lifecycle ────────────────────────────────────────────
55    /// Certificate enrollment request received.
56    EnrollRequest,
57    /// Certificate issued successfully.
58    CertIssue,
59    /// Certificate re-enrollment completed.
60    CertReenroll,
61    /// Enrollment request rejected.
62    EnrollReject,
63    /// Certificate revoked.
64    CertRevoke,
65    /// CRL generated.
66    CrlGenerate,
67
68    // ── Key management ───────────────────────────────────────────────────
69    /// Signing key generated (software or HSM).
70    KeyGenerate,
71    /// Signing key loaded from file or HSM.
72    KeyLoad,
73    /// Key destroyed or deactivated.
74    KeyDestroy,
75
76    // ── OTP lifecycle (RHELBU-3536 R7) ───────────────────────────────────
77    /// OTP created by administrator.
78    OtpCreate,
79    /// OTP used for enrollment authentication.
80    OtpUse,
81    /// OTP expired (TTL reached).
82    OtpExpire,
83    /// OTP revoked by administrator.
84    OtpRevoke,
85
86    // ── Authentication ───────────────────────────────────────────────────
87    /// Client authentication succeeded (mTLS, OTP, Basic, etc.).
88    AuthSuccess,
89    /// Client authentication failed.
90    AuthFailure,
91
92    // ── Admin operations ─────────────────────────────────────────────────
93    /// Admin operator logged in.
94    AdminLogin,
95    /// Admin operator logged out.
96    AdminLogout,
97    /// Admin performed a privileged operation.
98    AdminAction,
99
100    // ── Security anomalies ───────────────────────────────────────────────
101    /// Security violation detected (repeated auth failures, etc.).
102    SecurityViolation,
103}
104
105impl AuditEventType {
106    /// Return the canonical dot-separated string for this event type.
107    pub fn as_str(self) -> &'static str {
108        match self {
109            AuditEventType::CaStart => "ca.start",
110            AuditEventType::CaStop => "ca.stop",
111            AuditEventType::CaHealthChange => "ca.health-change",
112            AuditEventType::EnrollRequest => "enroll.request",
113            AuditEventType::CertIssue => "cert.issue",
114            AuditEventType::CertReenroll => "cert.reenroll",
115            AuditEventType::EnrollReject => "enroll.reject",
116            AuditEventType::CertRevoke => "cert.revoke",
117            AuditEventType::CrlGenerate => "crl.generate",
118            AuditEventType::KeyGenerate => "key.generate",
119            AuditEventType::KeyLoad => "key.load",
120            AuditEventType::KeyDestroy => "key.destroy",
121            AuditEventType::OtpCreate => "otp.create",
122            AuditEventType::OtpUse => "otp.use",
123            AuditEventType::OtpExpire => "otp.expire",
124            AuditEventType::OtpRevoke => "otp.revoke",
125            AuditEventType::AuthSuccess => "auth.success",
126            AuditEventType::AuthFailure => "auth.failure",
127            AuditEventType::AdminLogin => "admin.login",
128            AuditEventType::AdminLogout => "admin.logout",
129            AuditEventType::AdminAction => "admin.action",
130            AuditEventType::SecurityViolation => "security.violation",
131        }
132    }
133}
134
135/// A single audit event ready for recording.
136pub struct AuditEvent {
137    /// The type of auditable event.
138    pub event_type: AuditEventType,
139    /// CA identifier (when the event is CA-specific).
140    pub ca_id: Option<String>,
141    /// Subject of the event (e.g., certificate subject DN, operator name).
142    pub subject: Option<String>,
143    /// Human-readable detail string.
144    pub detail: Option<String>,
145    /// Client IP address (when applicable).
146    pub client_addr: Option<String>,
147    /// Operator identity (for admin actions).
148    pub operator: Option<String>,
149}
150
151impl AuditEvent {
152    /// Create a new audit event with the given type.
153    pub fn new(event_type: AuditEventType) -> Self {
154        Self {
155            event_type,
156            ca_id: None,
157            subject: None,
158            detail: None,
159            client_addr: None,
160            operator: None,
161        }
162    }
163
164    /// Builder: set the CA ID.
165    pub fn with_ca_id(mut self, ca_id: impl Into<String>) -> Self {
166        self.ca_id = Some(ca_id.into());
167        self
168    }
169
170    /// Builder: set the subject.
171    pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
172        self.subject = Some(subject.into());
173        self
174    }
175
176    /// Builder: set the detail.
177    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
178        self.detail = Some(detail.into());
179        self
180    }
181
182    /// Builder: set the client address.
183    pub fn with_client_addr(mut self, addr: impl Into<String>) -> Self {
184        self.client_addr = Some(addr.into());
185        self
186    }
187
188    /// Builder: set the operator.
189    pub fn with_operator(mut self, operator: impl Into<String>) -> Self {
190        self.operator = Some(operator.into());
191        self
192    }
193}
194
195/// Shared audit state (overflow flag, alarm counter).
196///
197/// Lives in `AppState` and survives the lifetime of the server process.
198/// Callers pass the database pool explicitly so the same state can be
199/// used from any async context.
200pub struct AuditState {
201    /// When `true`, EST operations MUST be rejected (FAU_STG.4 halt).
202    pub halted: AtomicBool,
203    config: crate::config::AuditConfig,
204    write_lock: tokio::sync::Mutex<()>,
205
206    /// Rolling count of consecutive security violations.
207    /// Reset to 0 after a successful authentication.
208    pub violation_count: AtomicU32,
209
210    /// HMAC key for keyed chain integrity (`signed = true`); `None` selects
211    /// the unkeyed SHA-256 chain (NIAP CA PP FAU_STG.1).
212    integrity_key: Option<Vec<u8>>,
213}
214
215impl AuditState {
216    /// Create a new audit state with no violations and not halted.
217    pub fn new() -> Self {
218        Self::with_config(crate::config::AuditConfig::default())
219    }
220
221    /// Build audit state from the `[audit]` config with the unkeyed
222    /// (SHA-256) hash chain.  Equivalent to [`from_config`] with no key.
223    ///
224    /// [`from_config`]: AuditState::from_config
225    pub fn with_config(config: crate::config::AuditConfig) -> Self {
226        Self::from_config(config, None)
227    }
228
229    /// Build audit state from the `[audit]` config plus a resolved integrity
230    /// key (present only when `signed = true`).  With a key the hash chain is
231    /// HMAC-SHA256 keyed; without one it is plain SHA-256.
232    pub fn from_config(config: crate::config::AuditConfig, integrity_key: Option<Vec<u8>>) -> Self {
233        Self {
234            config,
235            write_lock: tokio::sync::Mutex::new(()),
236            halted: AtomicBool::new(false),
237            violation_count: AtomicU32::new(0),
238            integrity_key,
239        }
240    }
241
242    /// Compute the chained hash `H(prev || RS || canonical)` in lowercase hex.
243    ///
244    /// `H` is HMAC-SHA256 keyed by [`integrity_key`](Self::integrity_key) when
245    /// one is configured, otherwise plain SHA-256.  The same [`AuditState`]
246    /// (key + algorithm) that wrote the rows must be used by [`verify_chain`],
247    /// or every recomputed hash mismatches.
248    fn chain_hash(&self, prev: Option<&str>, canonical: &[u8]) -> String {
249        let prev_bytes = prev.unwrap_or("").as_bytes();
250        match &self.integrity_key {
251            Some(key) => {
252                let mut mac =
253                    HmacSha256::new_from_slice(key).expect("HMAC accepts a key of any length");
254                mac.update(prev_bytes);
255                mac.update(&[RS]);
256                mac.update(canonical);
257                hex::encode(mac.finalize().into_bytes())
258            }
259            None => {
260                let mut h = Sha256::new();
261                h.update(prev_bytes);
262                h.update([RS]);
263                h.update(canonical);
264                hex::encode(h.finalize())
265            }
266        }
267    }
268
269    /// Check whether EST operations should be rejected due to audit
270    /// storage exhaustion (FAU_STG.4).
271    pub fn is_halted(&self) -> bool {
272        self.halted.load(Ordering::Relaxed)
273    }
274
275    /// Set the halted flag (called when audit storage is full and
276    /// overflow policy is `halt`).
277    pub fn set_halted(&self, halted: bool) {
278        self.halted.store(halted, Ordering::Relaxed);
279    }
280
281    /// Increment the security violation counter and return the new count.
282    pub fn record_violation(&self) -> u32 {
283        self.violation_count.fetch_add(1, Ordering::Relaxed) + 1
284    }
285
286    /// Reset the violation counter (called after successful authentication).
287    pub fn reset_violations(&self) {
288        self.violation_count.store(0, Ordering::Relaxed);
289    }
290}
291
292impl Default for AuditState {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298/// Record an audit event to the database.
299///
300/// When the database insert fails, the error is logged but not propagated
301/// to avoid failing EST operations due to audit backend issues (unless
302/// the overflow policy requires halting).
303pub async fn record(pool: &sqlx::AnyPool, state: &AuditState, event: AuditEvent) {
304    if let Err(error) = record_checked(pool, state, event).await {
305        tracing::error!(%error, "audit event could not be persisted");
306    }
307}
308
309/// Persist an event before a security-sensitive operation; propagate failure.
310pub async fn record_checked(
311    pool: &sqlx::AnyPool,
312    state: &AuditState,
313    event: AuditEvent,
314) -> Result<(), crate::error::KipukaError> {
315    if !state.config.enabled {
316        return Ok(());
317    }
318    let _guard = state.write_lock.lock().await;
319    let result = record_inner(pool, state, event).await;
320    if result.is_err() && state.config.overflow_policy == crate::config::OverflowPolicy::Halt {
321        state.set_halted(true);
322    }
323    result
324}
325
326async fn record_inner(
327    pool: &sqlx::AnyPool,
328    state: &AuditState,
329    event: AuditEvent,
330) -> Result<(), crate::error::KipukaError> {
331    if state.is_halted() {
332        return Err(KipukaError::Db("audit operation admission halted".into()));
333    }
334    let mut tx = pool
335        .begin()
336        .await
337        .map_err(|e| KipukaError::Db(e.to_string()))?;
338    write_event(&mut tx, state, &event).await?;
339    tx.commit()
340        .await
341        .map_err(|e| KipukaError::Db(e.to_string()))?;
342
343    // Track security violations for FAU_ARP.1
344    if event.event_type == AuditEventType::SecurityViolation {
345        let count = state.record_violation();
346        if count >= state.config.alarm_threshold {
347            tracing::error!(count, "AUDIT SECURITY ALARM");
348            if state.config.alarm_action == "halt" {
349                state.set_halted(true);
350            } else {
351                emit_syslog_alarm(count)
352                    .map_err(|e| KipukaError::Db(format!("audit syslog alarm failed: {e}")))?;
353            }
354        }
355        tracing::warn!(
356            consecutive_violations = count,
357            "security violation recorded"
358        );
359    } else if event.event_type == AuditEventType::AuthSuccess {
360        state.reset_violations();
361    }
362    Ok(())
363}
364
365/// Persist issuance auditing in the same transaction as certificate inventory.
366/// The caller must commit before publishing the certificate. Failures roll back
367/// both records; the database writer lock also enforces cross-process row bounds.
368pub(crate) async fn record_issuance_in_transaction(
369    tx: &mut sqlx::Transaction<'_, sqlx::Any>,
370    state: &AuditState,
371    ca_id: &str,
372    detail: String,
373) -> Result<(), crate::error::KipukaError> {
374    if !state.config.enabled {
375        return Ok(());
376    }
377    let event = AuditEvent::new(AuditEventType::CertIssue)
378        .with_ca_id(ca_id)
379        .with_detail(detail);
380    let result = write_event(tx, state, &event).await;
381    if result.is_err() && state.config.overflow_policy == crate::config::OverflowPolicy::Halt {
382        state.set_halted(true);
383    }
384    result
385}
386
387/// Commit inventory and issuance audit atomically, latching halt on durability failure.
388pub(crate) async fn commit_issuance(
389    tx: sqlx::Transaction<'_, sqlx::Any>,
390    state: &AuditState,
391) -> Result<(), crate::error::KipukaError> {
392    if let Err(error) = tx.commit().await {
393        if state.config.enabled
394            && state.config.overflow_policy == crate::config::OverflowPolicy::Halt
395        {
396            state.set_halted(true);
397        }
398        return Err(error.into());
399    }
400    Ok(())
401}
402
403/// The single insert point shared by every audit write path
404/// ([`record`]/[`record_checked`] and [`record_issuance_in_transaction`]).
405///
406/// Threading the tamper-evident hash chain (NIAP CA PP FAU_STG.1) through
407/// this one function — rather than through each caller — guarantees that the
408/// transactional issuance path is chained too: an un-chained row anywhere is a
409/// silent hole in the evidence.  The `audit_writer_lock` row is updated first,
410/// which takes a write lock held for the whole enclosing transaction; because
411/// the chain tail is then read (`SELECT ... ORDER BY id DESC LIMIT 1`) and the
412/// new row inserted under that same lock, the read-compute-append sequence is
413/// atomic against every other writer, including writers in other server
414/// processes.  Reading the tail from the database (not from in-memory state)
415/// keeps the chain correct across restarts and replicas.
416async fn write_event(
417    connection: &mut sqlx::AnyConnection,
418    state: &AuditState,
419    event: &AuditEvent,
420) -> Result<(), crate::error::KipukaError> {
421    if state.is_halted() {
422        return Err(KipukaError::Db("audit operation admission halted".into()));
423    }
424    // A database row lock serializes admission across distinct server processes.
425    // Acquire it before COUNT so bounded storage cannot race another writer.
426    sqlx::query("UPDATE audit_writer_lock SET revision = revision WHERE id = 1")
427        .execute(&mut *connection)
428        .await
429        .map_err(|e| KipukaError::Db(e.to_string()))?;
430    if let Some(limit) = state.config.max_rows {
431        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_events")
432            .fetch_one(&mut *connection)
433            .await
434            .map_err(|e| KipukaError::Db(e.to_string()))?;
435        if count >= limit as i64 {
436            if state.config.overflow_policy == crate::config::OverflowPolicy::Halt {
437                return Err(KipukaError::Db("audit storage row limit reached".into()));
438            }
439            let ids: Vec<i64> = sqlx::query_scalar(crate::db::pg_sql(
440                "SELECT id FROM audit_events ORDER BY id LIMIT ?",
441            ))
442            .bind(count - limit as i64 + 1)
443            .fetch_all(&mut *connection)
444            .await
445            .map_err(|e| KipukaError::Db(e.to_string()))?;
446            for id in ids {
447                sqlx::query(crate::db::pg_sql("DELETE FROM audit_events WHERE id = ?"))
448                    .bind(id)
449                    .execute(&mut *connection)
450                    .await
451                    .map_err(|e| KipukaError::Db(e.to_string()))?;
452            }
453        }
454    }
455    // Pack detail into detail_json with proper JSON escaping (no
456    // format!() interpolation that could allow injection via quotes or
457    // backslashes in detail/ca_id values).
458    let detail_json = match (&event.detail, &event.ca_id) {
459        (Some(d), Some(ca)) => Some(serde_json::json!({"detail": d, "ca_id": ca}).to_string()),
460        (Some(d), None) => Some(serde_json::json!({"detail": d}).to_string()),
461        (None, Some(ca)) => Some(serde_json::json!({"ca_id": ca}).to_string()),
462        (None, None) => None,
463    };
464
465    // ── Tamper-evident hash chain (FAU_STG.1) ──────────────────────────────
466    // Generate the timestamp in Rust (not via a DB DEFAULT) so the stored
467    // value is exactly what the chain commits to.  Read the current tail —
468    // still holding the writer lock acquired above — then chain onto it.  The
469    // enclosing transaction sees its own prior inserts, so multiple writes in
470    // one transaction chain correctly.
471    let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
472    let prev_hash: Option<String> = sqlx::query_scalar::<_, Option<String>>(
473        "SELECT record_hash FROM audit_events ORDER BY id DESC LIMIT 1",
474    )
475    .fetch_optional(&mut *connection)
476    .await
477    .map_err(|e| KipukaError::Db(format!("loading audit chain tail: {e}")))?
478    .flatten();
479    let canonical = canonical_bytes(
480        &timestamp,
481        event.event_type.as_str(),
482        event.operator.as_deref(),
483        event.subject.as_deref(),
484        detail_json.as_deref(),
485        event.client_addr.as_deref(),
486        None, // session_id is not yet populated
487    );
488    let record_hash = state.chain_hash(prev_hash.as_deref(), &canonical);
489
490    let sql = crate::db::pg_sql(
491        "INSERT INTO audit_events \
492         (timestamp, event_type, actor, target, detail_json, source_ip, session_id, prev_hash, record_hash) \
493         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
494    );
495    let result = sqlx::query(sql)
496        .bind(&timestamp)
497        .bind(event.event_type.as_str())
498        .bind(&event.operator)
499        .bind(&event.subject)
500        .bind(&detail_json)
501        .bind(&event.client_addr)
502        .bind(None::<String>)
503        .bind(&prev_hash)
504        .bind(&record_hash)
505        .execute(&mut *connection)
506        .await;
507
508    result.map_err(|e| KipukaError::Db(format!("audit insert failed: {e}")))?;
509    Ok(())
510}
511
512/// Build the deterministic byte serialization of a record for hashing.
513///
514/// Field order and separators MUST match between [`write_event`] (insert
515/// time) and [`verify_chain`] (verification time), or every hash would
516/// mismatch.  Each field is followed by an `RS` byte so that no field value
517/// can absorb an adjacent one to forge a colliding preimage.
518fn canonical_bytes(
519    timestamp: &str,
520    event_type: &str,
521    actor: Option<&str>,
522    target: Option<&str>,
523    detail_json: Option<&str>,
524    source_ip: Option<&str>,
525    session_id: Option<&str>,
526) -> Vec<u8> {
527    let mut buf = Vec::with_capacity(128);
528    for field in [
529        timestamp,
530        event_type,
531        actor.unwrap_or(""),
532        target.unwrap_or(""),
533        detail_json.unwrap_or(""),
534        source_ip.unwrap_or(""),
535        session_id.unwrap_or(""),
536    ] {
537        buf.extend_from_slice(field.as_bytes());
538        buf.push(RS);
539    }
540    buf
541}
542
543/// One `audit_events` row, as read back for chain verification.
544///
545/// The `Option` columns mirror the nullable table schema; the empty-string
546/// substitution in [`canonical_bytes`] means a `NULL` and an empty string
547/// hash identically, which is intentional — both encode "field absent".
548#[derive(sqlx::FromRow)]
549struct AuditRow {
550    id: i64,
551    timestamp: String,
552    event_type: String,
553    actor: Option<String>,
554    target: Option<String>,
555    detail_json: Option<String>,
556    source_ip: Option<String>,
557    session_id: Option<String>,
558    prev_hash: Option<String>,
559    record_hash: Option<String>,
560}
561
562/// Outcome of a full hash-chain verification pass (NIAP CA PP FAU_STG.1).
563///
564/// `ok == true` means every row's `record_hash` recomputed correctly and every
565/// `prev_hash` linked to its predecessor.  On failure, `broken_at` carries the
566/// `id` of the first offending row and `detail` explains which invariant broke.
567#[derive(Debug, Clone)]
568pub struct ChainVerifyReport {
569    /// Number of rows examined.
570    pub total: u64,
571    /// `true` when the entire chain verified.
572    pub ok: bool,
573    /// `id` of the first row that failed verification, if any.
574    pub broken_at: Option<i64>,
575    /// Human-readable description of the first failure, if any.
576    pub detail: Option<String>,
577}
578
579/// Re-walk the entire audit trail and verify the tamper-evident hash chain.
580///
581/// Reads every row in `id` order and checks, for each, that (1) its `prev_hash`
582/// equals the previous row's stored `record_hash` (linkage — detects deletion
583/// and reordering) and (2) its `record_hash` recomputes from its own fields via
584/// the configured `AuditState::chain_hash` (integrity — detects in-place
585/// edits).  The first violation short-circuits and is reported in
586/// [`ChainVerifyReport::broken_at`]/`detail`.
587///
588/// This is the review-time counterpart to the write-time chaining in
589/// `write_event`; the same [`AuditState`] (key + algorithm) that wrote the
590/// rows must be supplied, or unkeyed rows would be checked with an HMAC (or
591/// vice versa) and every hash would mismatch.
592pub async fn verify_chain(
593    pool: &sqlx::AnyPool,
594    state: &AuditState,
595) -> Result<ChainVerifyReport, KipukaError> {
596    let rows: Vec<AuditRow> = sqlx::query_as(
597        "SELECT id, timestamp, event_type, actor, target, detail_json, source_ip, \
598         session_id, prev_hash, record_hash FROM audit_events ORDER BY id ASC",
599    )
600    .fetch_all(pool)
601    .await
602    .map_err(|e| KipukaError::Db(format!("reading audit trail for verification: {e}")))?;
603
604    let total = rows.len() as u64;
605    let mut expected_prev: Option<String> = None;
606
607    for row in &rows {
608        // (1) Linkage: this row must point at the previous row's record_hash.
609        if row.prev_hash.as_deref() != expected_prev.as_deref() {
610            return Ok(ChainVerifyReport {
611                total,
612                ok: false,
613                broken_at: Some(row.id),
614                detail: Some(format!(
615                    "prev_hash linkage broken at id={}: stored prev_hash does not match the \
616                     preceding record_hash (row deleted, inserted, or reordered)",
617                    row.id
618                )),
619            });
620        }
621
622        // (2) Integrity: recompute this row's record_hash from its own fields.
623        let canonical = canonical_bytes(
624            &row.timestamp,
625            &row.event_type,
626            row.actor.as_deref(),
627            row.target.as_deref(),
628            row.detail_json.as_deref(),
629            row.source_ip.as_deref(),
630            row.session_id.as_deref(),
631        );
632        let recomputed = state.chain_hash(row.prev_hash.as_deref(), &canonical);
633        match &row.record_hash {
634            Some(stored) if *stored == recomputed => {}
635            _ => {
636                return Ok(ChainVerifyReport {
637                    total,
638                    ok: false,
639                    broken_at: Some(row.id),
640                    detail: Some(format!(
641                        "record_hash mismatch at id={}: row contents were altered after \
642                         insertion",
643                        row.id
644                    )),
645                });
646            }
647        }
648
649        expected_prev = row.record_hash.clone();
650    }
651
652    Ok(ChainVerifyReport {
653        total,
654        ok: true,
655        broken_at: None,
656        detail: None,
657    })
658}
659
660/// Send an actual LOG_AUTHPRIV/LOG_ALERT datagram to the system logger.
661#[cfg(unix)]
662fn emit_syslog_alarm(count: u32) -> std::io::Result<()> {
663    let socket = std::os::unix::net::UnixDatagram::unbound()?;
664    socket.set_write_timeout(Some(std::time::Duration::from_secs(1)))?;
665    #[cfg(target_os = "macos")]
666    let path = "/var/run/syslog";
667    #[cfg(not(target_os = "macos"))]
668    let path = "/dev/log";
669    socket.connect(path)?;
670    socket.send(
671        format!("<81>kipuka: audit security alarm: {count} consecutive violations").as_bytes(),
672    )?;
673    Ok(())
674}
675#[cfg(not(unix))]
676fn emit_syslog_alarm(_: u32) -> std::io::Result<()> {
677    Err(std::io::Error::new(
678        std::io::ErrorKind::Unsupported,
679        "syslog requires Unix",
680    ))
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn event_type_strings() {
689        assert_eq!(AuditEventType::CaStart.as_str(), "ca.start");
690        assert_eq!(AuditEventType::CertIssue.as_str(), "cert.issue");
691        assert_eq!(AuditEventType::OtpCreate.as_str(), "otp.create");
692        assert_eq!(AuditEventType::AuthFailure.as_str(), "auth.failure");
693        assert_eq!(AuditEventType::AdminLogin.as_str(), "admin.login");
694        assert_eq!(
695            AuditEventType::SecurityViolation.as_str(),
696            "security.violation"
697        );
698    }
699
700    #[test]
701    fn audit_state_violation_tracking() {
702        let state = AuditState::new();
703        assert_eq!(state.record_violation(), 1);
704        assert_eq!(state.record_violation(), 2);
705        state.reset_violations();
706        assert_eq!(state.record_violation(), 1);
707    }
708
709    #[test]
710    fn audit_state_halt_flag() {
711        let state = AuditState::new();
712        assert!(!state.is_halted());
713        state.set_halted(true);
714        assert!(state.is_halted());
715        state.set_halted(false);
716        assert!(!state.is_halted());
717    }
718
719    #[test]
720    fn audit_event_builder() {
721        let event = AuditEvent::new(AuditEventType::CertIssue)
722            .with_ca_id("production")
723            .with_subject("CN=device.example.com")
724            .with_detail("serial=ABC123")
725            .with_client_addr("10.0.0.1");
726
727        assert_eq!(event.event_type, AuditEventType::CertIssue);
728        assert_eq!(event.ca_id.as_deref(), Some("production"));
729        assert_eq!(event.subject.as_deref(), Some("CN=device.example.com"));
730        assert_eq!(event.detail.as_deref(), Some("serial=ABC123"));
731        assert_eq!(event.client_addr.as_deref(), Some("10.0.0.1"));
732        assert!(event.operator.is_none());
733    }
734}
735
736#[cfg(test)]
737mod issuance_commit_tests {
738    use super::*;
739    #[tokio::test]
740    async fn failed_commit_rolls_back_audit_and_latches_halt() {
741        let (db, kind) =
742            crate::db::init_pool(&crate::config::DbConfig::default(), "sqlite::memory:")
743                .await
744                .unwrap();
745        crate::db::run_migrations(&db, kind).await.unwrap();
746        sqlx::query("CREATE TABLE commit_failure (id INTEGER REFERENCES audit_writer_lock(id) DEFERRABLE INITIALLY DEFERRED)").execute(&db).await.unwrap();
747        let state = AuditState::with_config(crate::config::AuditConfig {
748            overflow_policy: crate::config::OverflowPolicy::Halt,
749            ..Default::default()
750        });
751        let mut tx = db.begin().await.unwrap();
752        sqlx::query("INSERT INTO commit_failure VALUES (999)")
753            .execute(&mut *tx)
754            .await
755            .unwrap();
756        record_issuance_in_transaction(&mut tx, &state, "synthetic", "commit regression".into())
757            .await
758            .unwrap();
759        assert!(commit_issuance(tx, &state).await.is_err());
760        assert!(state.is_halted());
761        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_events")
762            .fetch_one(&db)
763            .await
764            .unwrap();
765        assert_eq!(count, 0);
766    }
767}
768
769#[cfg(test)]
770mod chain_tests {
771    use super::*;
772
773    /// Spin up an in-memory SQLite DB with the full migration set applied.
774    async fn test_db() -> sqlx::AnyPool {
775        let (db, kind) =
776            crate::db::init_pool(&crate::config::DbConfig::default(), "sqlite::memory:")
777                .await
778                .unwrap();
779        crate::db::run_migrations(&db, kind).await.unwrap();
780        db
781    }
782
783    /// The hash chain is threaded through every write path — the best-effort
784    /// [`record`], the checked [`record_checked`], and the transactional
785    /// [`record_issuance_in_transaction`] — so exercising all three and then
786    /// verifying proves there is no un-chained hole in the evidence.
787    #[tokio::test]
788    async fn chain_verifies_across_all_write_paths() {
789        let db = test_db().await;
790        let state = AuditState::new();
791
792        // Path 1: record() (best-effort, wraps record_checked).
793        record(
794            &db,
795            &state,
796            AuditEvent::new(AuditEventType::AdminLogin).with_operator("admin"),
797        )
798        .await;
799
800        // Path 2: record_checked() (propagates failure).
801        record_checked(
802            &db,
803            &state,
804            AuditEvent::new(AuditEventType::EnrollRequest).with_detail("simpleenroll"),
805        )
806        .await
807        .unwrap();
808
809        // Path 3: record_issuance_in_transaction() (+ commit_issuance).
810        let mut tx = db.begin().await.unwrap();
811        record_issuance_in_transaction(&mut tx, &state, "ca-a", "serial=01".into())
812            .await
813            .unwrap();
814        // Two issuance rows in one transaction exercise intra-tx chaining.
815        record_issuance_in_transaction(&mut tx, &state, "ca-a", "serial=02".into())
816            .await
817            .unwrap();
818        commit_issuance(tx, &state).await.unwrap();
819
820        let report = verify_chain(&db, &state).await.unwrap();
821        assert_eq!(report.total, 4, "all four events must be present");
822        assert!(report.ok, "chain must verify: {:?}", report.detail);
823        assert!(report.broken_at.is_none());
824
825        // The first row anchors the chain with a NULL prev_hash.
826        let first_prev: Option<String> =
827            sqlx::query_scalar("SELECT prev_hash FROM audit_events ORDER BY id ASC LIMIT 1")
828                .fetch_one(&db)
829                .await
830                .unwrap();
831        assert!(first_prev.is_none(), "chain head has no predecessor");
832    }
833
834    /// Editing a row's contents in place must be detected: the stored
835    /// `record_hash` no longer recomputes from the altered fields.
836    #[tokio::test]
837    async fn tampering_with_a_row_breaks_the_chain() {
838        let db = test_db().await;
839        let state = AuditState::new();
840
841        for i in 0..3 {
842            record_checked(
843                &db,
844                &state,
845                AuditEvent::new(AuditEventType::AdminAction).with_detail(format!("op-{i}")),
846            )
847            .await
848            .unwrap();
849        }
850        assert!(verify_chain(&db, &state).await.unwrap().ok);
851
852        // Forge the detail of the middle row without updating its record_hash.
853        let victim: i64 =
854            sqlx::query_scalar("SELECT id FROM audit_events ORDER BY id ASC LIMIT 1 OFFSET 1")
855                .fetch_one(&db)
856                .await
857                .unwrap();
858        sqlx::query("UPDATE audit_events SET detail_json = ? WHERE id = ?")
859            .bind(Some(r#"{"detail":"tampered"}"#.to_string()))
860            .bind(victim)
861            .execute(&db)
862            .await
863            .unwrap();
864
865        let report = verify_chain(&db, &state).await.unwrap();
866        assert!(!report.ok, "tampering must be detected");
867        assert_eq!(report.broken_at, Some(victim));
868    }
869
870    /// Deleting a row must be detected via the prev_hash linkage check even
871    /// though every surviving row's own record_hash still recomputes.
872    #[tokio::test]
873    async fn deleting_a_row_breaks_linkage() {
874        let db = test_db().await;
875        let state = AuditState::new();
876
877        for i in 0..3 {
878            record_checked(
879                &db,
880                &state,
881                AuditEvent::new(AuditEventType::AdminAction).with_detail(format!("op-{i}")),
882            )
883            .await
884            .unwrap();
885        }
886
887        // Delete the middle row: row 3's prev_hash now dangles.
888        let victim: i64 =
889            sqlx::query_scalar("SELECT id FROM audit_events ORDER BY id ASC LIMIT 1 OFFSET 1")
890                .fetch_one(&db)
891                .await
892                .unwrap();
893        sqlx::query("DELETE FROM audit_events WHERE id = ?")
894            .bind(victim)
895            .execute(&db)
896            .await
897            .unwrap();
898
899        let report = verify_chain(&db, &state).await.unwrap();
900        assert!(!report.ok, "deletion must break linkage");
901    }
902
903    /// With keyed HMAC (`signed = true`), an attacker who can write to the
904    /// database but lacks the key cannot forge a valid `record_hash`.  Here we
905    /// simulate the forgery attempt by recomputing the chain with the *wrong*
906    /// key (an unkeyed verifier) — verification must fail.
907    #[tokio::test]
908    async fn keyed_hmac_detects_forgery_without_the_key() {
909        let db = test_db().await;
910        let signed_cfg = crate::config::AuditConfig {
911            signed: true,
912            ..Default::default()
913        };
914        let keyed = AuditState::from_config(signed_cfg, Some(b"super-secret-key".to_vec()));
915
916        for i in 0..3 {
917            record_checked(
918                &db,
919                &keyed,
920                AuditEvent::new(AuditEventType::CertIssue).with_detail(format!("serial-{i}")),
921            )
922            .await
923            .unwrap();
924        }
925
926        // With the correct key, the chain verifies.
927        assert!(verify_chain(&db, &keyed).await.unwrap().ok);
928
929        // An attacker forges a row's record_hash using the *unkeyed* SHA-256
930        // algorithm (they don't have the HMAC key).  Recompute what they would
931        // write for the first row and plant it.
932        let first = &sqlx::query_as::<_, AuditRow>(
933            "SELECT id, timestamp, event_type, actor, target, detail_json, source_ip, \
934             session_id, prev_hash, record_hash FROM audit_events ORDER BY id ASC LIMIT 1",
935        )
936        .fetch_all(&db)
937        .await
938        .unwrap()[0];
939        let unkeyed = AuditState::new();
940        let canonical = canonical_bytes(
941            &first.timestamp,
942            &first.event_type,
943            first.actor.as_deref(),
944            first.target.as_deref(),
945            Some(r#"{"detail":"forged"}"#),
946            first.source_ip.as_deref(),
947            first.session_id.as_deref(),
948        );
949        let forged = unkeyed.chain_hash(first.prev_hash.as_deref(), &canonical);
950        sqlx::query("UPDATE audit_events SET detail_json = ?, record_hash = ? WHERE id = ?")
951            .bind(Some(r#"{"detail":"forged"}"#.to_string()))
952            .bind(Some(forged))
953            .bind(first.id)
954            .execute(&db)
955            .await
956            .unwrap();
957
958        // The keyed verifier rejects the forgery: the unkeyed hash is not a
959        // valid HMAC under the secret key.
960        let report = verify_chain(&db, &keyed).await.unwrap();
961        assert!(
962            !report.ok,
963            "keyed HMAC must reject a forgery made without the key"
964        );
965        assert_eq!(report.broken_at, Some(first.id));
966    }
967}