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