Skip to main content

citadel/
builder.rs

1use std::fs::{self, OpenOptions};
2use std::io::{Read, Seek, SeekFrom};
3use std::path::PathBuf;
4
5use citadel_core::types::{Argon2Profile, CipherId, KdfAlgorithm, SyncMode};
6use citadel_core::{
7    Error, Result, DEFAULT_BUFFER_POOL_SIZE, FILE_HEADER_SIZE, KEY_FILE_SIZE, PBKDF2_MIN_ITERATIONS,
8};
9use citadel_crypto::key_manager::{create_key_file, open_key_file};
10use citadel_crypto::page_cipher::compute_dek_id;
11use citadel_io::durable;
12use citadel_io::file_lock;
13use citadel_io::file_manager::FileHeader;
14use citadel_io::sync_io::SyncPageIO;
15use citadel_io::traits::PageIO;
16use citadel_txn::manager::TxnManager;
17
18use crate::database::Database;
19
20/// Builder for creating or opening a Citadel database.
21///
22/// # Examples
23///
24/// ```no_run
25/// use citadel::{DatabaseBuilder, Argon2Profile};
26///
27/// let db = DatabaseBuilder::new("mydb.citadel")
28///     .passphrase(b"secret")
29///     .cache_size(512)
30///     .create()
31///     .unwrap();
32/// ```
33pub struct DatabaseBuilder {
34    path: PathBuf,
35    key_path: Option<PathBuf>,
36    passphrase: Option<Vec<u8>>,
37    argon2_profile: Argon2Profile,
38    cache_size: usize,
39    cipher: CipherId,
40    kdf_algorithm: KdfAlgorithm,
41    pbkdf2_iterations: u32,
42    sync_mode: SyncMode,
43    #[cfg(feature = "audit-log")]
44    audit_config: crate::audit::AuditConfig,
45}
46
47impl DatabaseBuilder {
48    pub fn new(path: impl Into<PathBuf>) -> Self {
49        Self {
50            path: path.into(),
51            key_path: None,
52            passphrase: None,
53            argon2_profile: Argon2Profile::Desktop,
54            cache_size: DEFAULT_BUFFER_POOL_SIZE,
55            cipher: CipherId::Aes256Ctr,
56            kdf_algorithm: KdfAlgorithm::Argon2id,
57            pbkdf2_iterations: PBKDF2_MIN_ITERATIONS,
58            sync_mode: SyncMode::Full,
59            #[cfg(feature = "audit-log")]
60            audit_config: crate::audit::AuditConfig::default(),
61        }
62    }
63
64    pub fn passphrase(mut self, passphrase: &[u8]) -> Self {
65        self.passphrase = Some(passphrase.to_vec());
66        self
67    }
68
69    pub fn key_path(mut self, path: impl Into<PathBuf>) -> Self {
70        self.key_path = Some(path.into());
71        self
72    }
73
74    pub fn argon2_profile(mut self, profile: Argon2Profile) -> Self {
75        self.argon2_profile = profile;
76        self
77    }
78
79    pub fn cache_size(mut self, pages: usize) -> Self {
80        self.cache_size = pages;
81        self
82    }
83
84    pub fn cipher(mut self, cipher: CipherId) -> Self {
85        self.cipher = cipher;
86        self
87    }
88
89    /// Set the key derivation function algorithm.
90    ///
91    /// Default: `Argon2id`. Use `Pbkdf2HmacSha256` for FIPS 140-3 compliance.
92    /// When using PBKDF2, the Argon2 profile is ignored and iterations are
93    /// controlled by `pbkdf2_iterations()`.
94    pub fn kdf_algorithm(mut self, algorithm: KdfAlgorithm) -> Self {
95        self.kdf_algorithm = algorithm;
96        self
97    }
98
99    /// Set the number of PBKDF2 iterations (only used when KDF is PBKDF2).
100    ///
101    /// Default: 600,000 (OWASP 2024 minimum for PBKDF2-HMAC-SHA256).
102    pub fn pbkdf2_iterations(mut self, iterations: u32) -> Self {
103        self.pbkdf2_iterations = iterations;
104        self
105    }
106
107    pub fn sync_mode(mut self, mode: SyncMode) -> Self {
108        self.sync_mode = mode;
109        self
110    }
111
112    /// Configure the audit log.
113    ///
114    /// Default: enabled with 10 MB max file size and 3 rotated files.
115    #[cfg(feature = "audit-log")]
116    pub fn audit_config(mut self, config: crate::audit::AuditConfig) -> Self {
117        self.audit_config = config;
118        self
119    }
120
121    /// Default key file path: `{data_path}.citadel-keys`
122    fn resolve_key_path(&self) -> PathBuf {
123        self.key_path.clone().unwrap_or_else(|| {
124            let mut name = self.path.as_os_str().to_os_string();
125            name.push(".citadel-keys");
126            PathBuf::from(name)
127        })
128    }
129
130    fn create_page_io(file: std::fs::File) -> Box<dyn PageIO> {
131        #[cfg(all(target_os = "linux", feature = "io-uring"))]
132        {
133            if let Some(uring) = citadel_io::uring_io::UringPageIO::try_new(
134                file.try_clone().expect("failed to clone file handle"),
135            ) {
136                return Box::new(uring);
137            }
138        }
139        Box::new(SyncPageIO::new(file))
140    }
141
142    /// Resolve KDF parameters: (m_cost, t_cost, p_cost) for Argon2id,
143    /// or (iterations, 0, 0) for PBKDF2.
144    fn resolve_kdf_params(&self) -> (u32, u32, u32) {
145        match self.kdf_algorithm {
146            KdfAlgorithm::Argon2id => {
147                let profile = self.argon2_profile;
148                (profile.m_cost(), profile.t_cost(), profile.p_cost())
149            }
150            KdfAlgorithm::Pbkdf2HmacSha256 => (self.pbkdf2_iterations, 0, 0),
151        }
152    }
153
154    /// Validate configuration against FIPS constraints (when fips feature enabled).
155    #[cfg(feature = "fips")]
156    fn validate_fips(&self) -> Result<()> {
157        if self.kdf_algorithm != KdfAlgorithm::Pbkdf2HmacSha256 {
158            return Err(Error::FipsViolation(
159                "FIPS mode requires PBKDF2-HMAC-SHA256 (Argon2id is not NIST approved)".into(),
160            ));
161        }
162        if self.cipher == CipherId::ChaCha20 {
163            return Err(Error::FipsViolation(
164                "FIPS mode requires AES-256-CTR (ChaCha20 is not NIST approved)".into(),
165            ));
166        }
167        Ok(())
168    }
169
170    /// Build a `Database` from a `TxnManager`, optionally creating or opening
171    /// an audit log. Centralizes the audit-log feature gating.
172    #[cfg(feature = "audit-log")]
173    fn finish(
174        self,
175        manager: TxnManager,
176        key_path: PathBuf,
177        file_id: u64,
178        audit_key: [u8; citadel_core::KEY_SIZE],
179        initial_event: Option<(crate::audit::AuditEventType, Vec<u8>)>,
180    ) -> Result<Database> {
181        use crate::audit;
182
183        let audit_log = if self.audit_config.enabled && !self.path.as_os_str().is_empty() {
184            let audit_path = audit::resolve_audit_path(&self.path);
185            let log = if audit_path.exists() {
186                audit::AuditLog::open_existing(&audit_path, file_id, audit_key, self.audit_config)?
187            } else {
188                audit::AuditLog::create(&audit_path, file_id, audit_key, self.audit_config)?
189            };
190            Some(log)
191        } else {
192            None
193        };
194
195        let db = Database::new(manager, self.path, key_path, audit_log);
196
197        if let Some((event, detail)) = initial_event {
198            db.log_audit(event, &detail);
199        }
200
201        Ok(db)
202    }
203
204    #[cfg(not(feature = "audit-log"))]
205    fn finish(
206        self,
207        manager: TxnManager,
208        key_path: PathBuf,
209        _file_id: u64,
210        _audit_key: [u8; citadel_core::KEY_SIZE],
211        _initial_event: Option<((), Vec<u8>)>,
212    ) -> Result<Database> {
213        Ok(Database::new(manager, self.path, key_path))
214    }
215
216    /// Create a new database. Fails if the data file already exists.
217    pub fn create(self) -> Result<Database> {
218        #[cfg(feature = "fips")]
219        self.validate_fips()?;
220
221        let passphrase = self
222            .passphrase
223            .as_deref()
224            .ok_or(Error::PassphraseRequired)?;
225
226        let key_path = self.resolve_key_path();
227        let file_id: u64 = rand::random();
228        let (m_cost, t_cost, p_cost) = self.resolve_kdf_params();
229
230        let (kf, keys) = create_key_file(
231            passphrase,
232            file_id,
233            self.cipher,
234            self.kdf_algorithm,
235            m_cost,
236            t_cost,
237            p_cost,
238        )?;
239
240        // Write key file to disk with fsync + directory sync
241        durable::write_and_sync(&key_path, &kf.serialize())?;
242
243        // Create data file (fail if exists)
244        let file = OpenOptions::new()
245            .read(true)
246            .write(true)
247            .create_new(true)
248            .open(&self.path)?;
249
250        file_lock::try_lock_exclusive(&file)?;
251
252        let dek_id = compute_dek_id(&keys.mac_key, &keys.dek);
253        let io = Self::create_page_io(file);
254
255        let manager = TxnManager::create_with_sync(
256            io,
257            keys.dek,
258            keys.mac_key,
259            kf.current_epoch,
260            file_id,
261            dek_id,
262            self.cache_size,
263            self.sync_mode,
264        )?;
265
266        #[cfg(feature = "audit-log")]
267        let event = {
268            let detail = vec![self.cipher as u8, self.kdf_algorithm as u8];
269            Some((crate::audit::AuditEventType::DatabaseCreated, detail))
270        };
271        #[cfg(not(feature = "audit-log"))]
272        let event: Option<((), Vec<u8>)> = None;
273
274        self.finish(manager, key_path, file_id, keys.audit_key, event)
275    }
276
277    /// Create a new in-memory database (volatile, no file I/O).
278    ///
279    /// Data exists only for the lifetime of the returned `Database`.
280    /// Useful for testing, caching, and WASM environments.
281    pub fn create_in_memory(mut self) -> Result<Database> {
282        #[cfg(feature = "fips")]
283        self.validate_fips()?;
284
285        let passphrase = self
286            .passphrase
287            .as_deref()
288            .ok_or(Error::PassphraseRequired)?;
289
290        let file_id: u64 = rand::random();
291        let (m_cost, t_cost, p_cost) = self.resolve_kdf_params();
292
293        let (_kf, keys) = create_key_file(
294            passphrase,
295            file_id,
296            self.cipher,
297            self.kdf_algorithm,
298            m_cost,
299            t_cost,
300            p_cost,
301        )?;
302
303        let dek_id = compute_dek_id(&keys.mac_key, &keys.dek);
304        let io: Box<dyn PageIO> = Box::new(citadel_io::memory_io::MemoryPageIO::new());
305
306        let manager = TxnManager::create_with_sync(
307            io,
308            keys.dek,
309            keys.mac_key,
310            1,
311            file_id,
312            dek_id,
313            self.cache_size,
314            self.sync_mode,
315        )?;
316
317        // Clear path so finish() won't create an audit log file on disk
318        self.path = PathBuf::new();
319        self.finish(manager, PathBuf::new(), file_id, keys.audit_key, None)
320    }
321
322    /// Open an existing database. Fails if the data file does not exist.
323    pub fn open(self) -> Result<Database> {
324        let passphrase = self
325            .passphrase
326            .as_deref()
327            .ok_or(Error::PassphraseRequired)?;
328
329        let key_path = self.resolve_key_path();
330
331        // Open data file
332        let mut file = OpenOptions::new().read(true).write(true).open(&self.path)?;
333
334        file_lock::try_lock_exclusive(&file)?;
335
336        // Read file header to get file_id
337        let mut header_buf = [0u8; FILE_HEADER_SIZE];
338        file.seek(SeekFrom::Start(0))?;
339        file.read_exact(&mut header_buf)?;
340        let header = FileHeader::deserialize(&header_buf)?;
341
342        // Read and validate key file
343        let key_data = fs::read(&key_path)?;
344        if key_data.len() != KEY_FILE_SIZE {
345            return Err(Error::Io(std::io::Error::new(
346                std::io::ErrorKind::InvalidData,
347                "key file has incorrect size",
348            )));
349        }
350        let key_buf: [u8; KEY_FILE_SIZE] = key_data.try_into().unwrap();
351        let (kf, keys) = open_key_file(&key_buf, passphrase, header.file_id)?;
352
353        let dek_id = compute_dek_id(&keys.mac_key, &keys.dek);
354
355        let io = Self::create_page_io(file);
356
357        let manager = TxnManager::open_with_sync(
358            io,
359            keys.dek,
360            keys.mac_key,
361            kf.current_epoch,
362            self.cache_size,
363            self.sync_mode,
364        )?;
365
366        // Verify dek_id against the recovered commit slot
367        let slot = manager.current_slot();
368        if slot.dek_id != dek_id {
369            return Err(Error::BadPassphrase);
370        }
371
372        #[cfg(feature = "audit-log")]
373        let event = Some((crate::audit::AuditEventType::DatabaseOpened, vec![]));
374        #[cfg(not(feature = "audit-log"))]
375        let event: Option<((), Vec<u8>)> = None;
376
377        self.finish(manager, key_path, header.file_id, keys.audit_key, event)
378    }
379}