Skip to main content

kipuka/config/
audit.rs

1//! Audit configuration.
2//!
3//! The `[audit]` section controls the audit trail that satisfies NIAP CA PP
4//! FAU family requirements:
5//!
6//! - **FAU_GEN.1** — the server generates audit records for all security-relevant
7//!   events (enrollment, authentication, key operations, admin actions).
8//! - **FAU_STG.1** — the audit trail is append-only at the application level.
9//! - **FAU_STG.4** — when `overflow_policy = "halt"`, EST operations are rejected
10//!   if the audit trail storage is exhausted.
11//! - **FAU_ARP.1** — when the alarm threshold is reached, the configured alarm
12//!   action is triggered.
13
14use serde::Deserialize;
15
16use super::SecretRef;
17
18/// Audit log rotation policy.
19#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "lowercase")]
21#[derive(Default)]
22pub enum RotationPolicy {
23    /// Rotate based on file size.
24    Size,
25    /// Rotate daily.
26    #[default]
27    Daily,
28    /// Rotate weekly.
29    Weekly,
30    /// Never rotate (rely on external log management).
31    Never,
32}
33
34/// What to do when audit storage is exhausted (FAU_STG.4).
35#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37#[derive(Default)]
38pub enum OverflowPolicy {
39    /// Drop the oldest audit records to make room.
40    #[default]
41    DropOldest,
42    /// Halt EST operations until audit storage is cleared.
43    ///
44    /// NIAP CA PP FAU_STG.4: "The TSF shall prevent audited events,
45    /// except those taken by the authorised administrator, if the
46    /// audit trail is full."
47    Halt,
48}
49
50/// `[audit]` section — audit trail configuration.
51///
52/// ```toml
53/// [audit]
54/// enabled = true
55/// log_path = "/var/log/kipuka/audit.log"
56/// signed = true
57/// rotation_policy = "daily"
58/// overflow_policy = "halt"
59/// max_rows = 1000000
60/// ```
61#[derive(Debug, Clone, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct AuditConfig {
64    /// Enable audit logging.  Default: `true`.
65    #[serde(default = "bool_true")]
66    pub enabled: bool,
67
68    /// Path to the audit log file.
69    ///
70    /// When using database-backed audit (`log_to_db = true`), this path
71    /// is used for the file-based backup copy.
72    #[serde(default = "default_log_path")]
73    pub log_path: String,
74
75    /// Enable keyed (HMAC) integrity protection of the audit hash chain.
76    ///
77    /// Every audit row stores `record_hash = H(prev_hash || record_bytes)`,
78    /// forming a tamper-evident chain (NIAP CA PP FAU_STG.1).  When `signed`
79    /// is `false` (the default), `H` is plain SHA-256 — this detects in-place
80    /// edits, deletions, and reordering as long as the chain head is anchored
81    /// out-of-band.  When `signed` is `true`, `H` is HMAC-SHA256 keyed by
82    /// [`integrity_key`](Self::integrity_key), which additionally prevents an
83    /// attacker who can write to the database from forging a valid chain.
84    #[serde(default)]
85    pub signed: bool,
86
87    /// Secret reference for the HMAC integrity key (required when
88    /// `signed = true`).
89    ///
90    /// Resolved at startup via the standard [`SecretRef`] backends
91    /// (`env:`, `file:`, `keyring:`, `systemd-creds:`).  Never store the raw
92    /// key inline in the config file.
93    #[serde(default)]
94    pub integrity_key: Option<SecretRef>,
95
96    /// Log rotation policy.
97    #[serde(default)]
98    pub rotation_policy: RotationPolicy,
99
100    /// Maximum rotation file size in bytes (when `rotation_policy = "size"`).
101    /// Default: 100 MiB.
102    #[serde(default = "default_max_file_size")]
103    pub max_file_size: u64,
104
105    /// Number of rotated log files to retain.
106    /// Default: 10.
107    #[serde(default = "default_retention_count")]
108    pub retention_count: u32,
109
110    /// Store audit events in the database in addition to the log file.
111    #[serde(default = "bool_true")]
112    pub log_to_db: bool,
113
114    /// Overflow policy when audit storage is full (FAU_STG.4).
115    #[serde(default)]
116    pub overflow_policy: OverflowPolicy,
117
118    /// Maximum number of audit rows in the database.
119    ///
120    /// When this limit is reached, the `overflow_policy` determines
121    /// whether old rows are dropped or EST operations are halted.
122    /// `None` means no limit (rely on disk space monitoring).
123    pub max_rows: Option<u64>,
124
125    /// Number of consecutive security violations before the alarm
126    /// action fires (FAU_ARP.1).
127    ///
128    /// Default: 10.
129    #[serde(default = "default_alarm_threshold")]
130    pub alarm_threshold: u32,
131
132    /// Action taken when the alarm threshold is reached.
133    ///
134    /// - `"syslog"` — emit a syslog alert.
135    /// - `"halt"` — halt EST operations.
136    ///
137    /// Default: `"syslog"`.
138    #[serde(default = "default_alarm_action")]
139    pub alarm_action: String,
140
141    /// NIAP CA PP FAU_GEN.1: list of auditable event types.
142    ///
143    /// When non-empty, only these event types are recorded.
144    /// When empty (default), all events are audited.
145    #[serde(default)]
146    pub auditable_events: Vec<String>,
147
148    /// Override the interval (in seconds) between rotation checks.
149    ///
150    /// When `None` (the default), the interval is derived from the
151    /// `rotation_policy`: 3600s for `size`, 86400s for `daily`/`weekly`.
152    #[serde(default)]
153    pub rotation_check_interval_secs: Option<u64>,
154}
155
156fn bool_true() -> bool {
157    true
158}
159
160fn default_log_path() -> String {
161    "/var/log/kipuka/audit.log".to_string()
162}
163
164fn default_max_file_size() -> u64 {
165    100 * 1024 * 1024 // 100 MiB
166}
167
168fn default_retention_count() -> u32 {
169    10
170}
171
172fn default_alarm_threshold() -> u32 {
173    10
174}
175
176fn default_alarm_action() -> String {
177    "syslog".to_string()
178}
179
180impl Default for AuditConfig {
181    fn default() -> Self {
182        Self {
183            enabled: true,
184            log_path: default_log_path(),
185            signed: false,
186            integrity_key: None,
187            rotation_policy: RotationPolicy::default(),
188            max_file_size: default_max_file_size(),
189            retention_count: default_retention_count(),
190            log_to_db: true,
191            overflow_policy: OverflowPolicy::default(),
192            max_rows: None,
193            alarm_threshold: default_alarm_threshold(),
194            alarm_action: default_alarm_action(),
195            auditable_events: Vec::new(),
196            rotation_check_interval_secs: None,
197        }
198    }
199}
200
201impl AuditConfig {
202    /// Validate audit configuration constraints.
203    pub fn validate(&self) -> std::result::Result<(), String> {
204        if !self.enabled {
205            return Ok(());
206        }
207
208        if self.log_path != default_log_path()
209            || self.rotation_policy != RotationPolicy::Daily
210            || self.max_file_size != default_max_file_size()
211            || self.retention_count != default_retention_count()
212            || self.rotation_check_interval_secs.is_some()
213        {
214            return Err("file audit output/rotation settings are unsupported; configure the external logging sink instead".into());
215        }
216        if !self.auditable_events.is_empty() {
217            return Err(
218                "audit event filtering is unsupported; all security events must be recorded".into(),
219            );
220        }
221        if self.max_rows.is_some_and(|n| n > i64::MAX as u64) {
222            return Err("[audit].max_rows exceeds the database integer range".into());
223        }
224        // Keyed integrity (HMAC) requires a resolvable, non-empty key.  The
225        // unkeyed SHA-256 chain is always active and needs no configuration;
226        // `signed = true` upgrades it to HMAC-SHA256 keyed by `integrity_key`.
227        let integrity_key_missing = match &self.integrity_key {
228            None => true,
229            Some(k) => k.is_empty(),
230        };
231        if self.signed && integrity_key_missing {
232            return Err(
233                "[audit].integrity_key is required (and must be non-empty) when signed = true"
234                    .into(),
235            );
236        }
237        if self.max_rows == Some(0) {
238            return Err("[audit].max_rows must be positive".into());
239        }
240        if !self.log_to_db {
241            return Err("[audit].log_to_db must be true: file-only audit is unsupported".into());
242        }
243        match self.alarm_action.as_str() {
244            "syslog" | "halt" => {}
245            other => {
246                return Err(format!(
247                    "[audit].alarm_action must be \"syslog\" or \"halt\", got {other:?}"
248                ));
249            }
250        }
251
252        if self.alarm_threshold == 0 {
253            return Err("[audit].alarm_threshold must be at least 1".into());
254        }
255
256        if self.rotation_policy == RotationPolicy::Size && self.max_file_size == 0 {
257            return Err(
258                "[audit].max_file_size must be at least 1 when rotation_policy = \"size\"".into(),
259            );
260        }
261
262        if let Some(0) = self.rotation_check_interval_secs {
263            return Err("[audit].rotation_check_interval_secs must be at least 1 when set".into());
264        }
265
266        Ok(())
267    }
268}