Skip to main content

citadel/
builder.rs

1#[cfg(not(target_arch = "wasm32"))]
2use std::fs::{self, OpenOptions};
3#[cfg(not(target_arch = "wasm32"))]
4use std::io::{Read, Seek, SeekFrom};
5use std::path::PathBuf;
6
7use citadel_core::types::{Argon2Profile, CipherId, KdfAlgorithm, SyncMode};
8use citadel_core::{Error, Result, DEFAULT_BUFFER_POOL_SIZE, PBKDF2_MIN_ITERATIONS};
9#[cfg(not(target_arch = "wasm32"))]
10use citadel_core::{FILE_HEADER_SIZE, KEY_FILE_SIZE};
11use citadel_crypto::hkdf_utils::RegionWrapKeys;
12use citadel_crypto::key_manager::{create_key_file, create_key_file_with_region_keys};
13#[cfg(not(target_arch = "wasm32"))]
14use citadel_crypto::key_manager::{open_key_file, open_key_file_with_region_keys};
15use citadel_crypto::page_cipher::compute_dek_id;
16#[cfg(not(target_arch = "wasm32"))]
17use citadel_io::durable;
18#[cfg(not(target_arch = "wasm32"))]
19use citadel_io::file_lock;
20#[cfg(not(target_arch = "wasm32"))]
21use citadel_io::file_manager::FileHeader;
22#[cfg(not(target_arch = "wasm32"))]
23use citadel_io::mmap_io::MmapPageIO;
24use citadel_io::traits::PageIO;
25use citadel_txn::manager::TxnManager;
26
27use crate::database::Database;
28
29/// Builder for creating or opening a Citadel database.
30///
31/// # Examples
32///
33/// ```no_run
34/// use citadel::{DatabaseBuilder, Argon2Profile};
35///
36/// let db = DatabaseBuilder::new("mydb.citadel")
37///     .passphrase(b"secret")
38///     .cache_size(512)
39///     .create()
40///     .unwrap();
41/// ```
42pub struct DatabaseBuilder {
43    path: PathBuf,
44    key_path: Option<PathBuf>,
45    passphrase: Option<Vec<u8>>,
46    argon2_profile: Argon2Profile,
47    cache_size: usize,
48    cipher: CipherId,
49    kdf_algorithm: KdfAlgorithm,
50    pbkdf2_iterations: u32,
51    sync_mode: SyncMode,
52    enable_region_keys: bool,
53    secure_delete: bool,
54    #[cfg(feature = "audit-log")]
55    audit_config: crate::audit::AuditConfig,
56}
57
58impl DatabaseBuilder {
59    pub fn new(path: impl Into<PathBuf>) -> Self {
60        Self {
61            path: path.into(),
62            key_path: None,
63            passphrase: None,
64            argon2_profile: Argon2Profile::Desktop,
65            cache_size: DEFAULT_BUFFER_POOL_SIZE,
66            cipher: CipherId::Aes256Ctr,
67            kdf_algorithm: KdfAlgorithm::Argon2id,
68            pbkdf2_iterations: PBKDF2_MIN_ITERATIONS,
69            sync_mode: SyncMode::Full,
70            enable_region_keys: false,
71            secure_delete: false,
72            #[cfg(feature = "audit-log")]
73            audit_config: crate::audit::AuditConfig::default(),
74        }
75    }
76
77    pub fn passphrase(mut self, passphrase: &[u8]) -> Self {
78        self.passphrase = Some(passphrase.to_vec());
79        self
80    }
81
82    pub fn key_path(mut self, path: impl Into<PathBuf>) -> Self {
83        self.key_path = Some(path.into());
84        self
85    }
86
87    pub fn argon2_profile(mut self, profile: Argon2Profile) -> Self {
88        self.argon2_profile = profile;
89        self
90    }
91
92    pub fn cache_size(mut self, pages: usize) -> Self {
93        self.cache_size = pages;
94        self
95    }
96
97    pub fn cipher(mut self, cipher: CipherId) -> Self {
98        self.cipher = cipher;
99        self
100    }
101
102    /// Set the key derivation function algorithm.
103    ///
104    /// Default: `Argon2id`. Use `Pbkdf2HmacSha256` for FIPS 140-3 compliance.
105    /// When using PBKDF2, the Argon2 profile is ignored and iterations are
106    /// controlled by `pbkdf2_iterations()`.
107    pub fn kdf_algorithm(mut self, algorithm: KdfAlgorithm) -> Self {
108        self.kdf_algorithm = algorithm;
109        self
110    }
111
112    /// Set the number of PBKDF2 iterations (only used when KDF is PBKDF2).
113    ///
114    /// Default: 600,000 (OWASP 2024 minimum for PBKDF2-HMAC-SHA256).
115    pub fn pbkdf2_iterations(mut self, iterations: u32) -> Self {
116        self.pbkdf2_iterations = iterations;
117        self
118    }
119
120    pub fn sync_mode(mut self, mode: SyncMode) -> Self {
121        self.sync_mode = mode;
122        self
123    }
124
125    /// Enable per-region cryptographic erasure (used by citadel-mem).
126    ///
127    /// When set, a region wrap key is derived from the REK at create/open and
128    /// retained for the database lifetime so encrypted memory regions can be
129    /// sealed under random per-region keys and erased on `forget`. Off by
130    /// default; the plaintext storage path is unaffected either way.
131    pub fn enable_region_keys(mut self, enable: bool) -> Self {
132        self.enable_region_keys = enable;
133        self
134    }
135
136    /// Zero-fill freed B+ tree pages once they are past all readers, so a passphrase holder
137    /// with disk access cannot recover deleted-row residue from stale pages. Off by default
138    /// (a small write cost on delete-heavy workloads).
139    pub fn enable_secure_delete(mut self, enable: bool) -> Self {
140        self.secure_delete = enable;
141        self
142    }
143
144    /// Configure the audit log.
145    ///
146    /// Default: enabled with 10 MB max file size and 3 rotated files.
147    #[cfg(feature = "audit-log")]
148    pub fn audit_config(mut self, config: crate::audit::AuditConfig) -> Self {
149        self.audit_config = config;
150        self
151    }
152
153    /// Default key file path: `{data_path}.citadel-keys`
154    #[cfg(not(target_arch = "wasm32"))]
155    fn resolve_key_path(&self) -> PathBuf {
156        self.key_path.clone().unwrap_or_else(|| {
157            let mut name = self.path.as_os_str().to_os_string();
158            name.push(".citadel-keys");
159            PathBuf::from(name)
160        })
161    }
162
163    #[cfg(not(target_arch = "wasm32"))]
164    fn create_page_io(file: std::fs::File) -> Box<dyn PageIO> {
165        #[cfg(all(target_os = "linux", feature = "io-uring"))]
166        {
167            if let Some(uring) = citadel_io::uring_io::UringPageIO::try_new(
168                file.try_clone().expect("failed to clone file handle"),
169            ) {
170                return Box::new(uring);
171            }
172        }
173        Box::new(MmapPageIO::try_new(file).expect("mmap init failed"))
174    }
175
176    /// Resolve KDF parameters: (m_cost, t_cost, p_cost) for Argon2id,
177    /// or (iterations, 0, 0) for PBKDF2.
178    fn resolve_kdf_params(&self) -> (u32, u32, u32) {
179        match self.kdf_algorithm {
180            KdfAlgorithm::Argon2id => {
181                let profile = self.argon2_profile;
182                (profile.m_cost(), profile.t_cost(), profile.p_cost())
183            }
184            KdfAlgorithm::Pbkdf2HmacSha256 => (self.pbkdf2_iterations, 0, 0),
185        }
186    }
187
188    /// Validate configuration against FIPS constraints (when fips feature enabled).
189    #[cfg(feature = "fips")]
190    fn validate_fips(&self) -> Result<()> {
191        if self.kdf_algorithm != KdfAlgorithm::Pbkdf2HmacSha256 {
192            return Err(Error::FipsViolation(
193                "FIPS mode requires PBKDF2-HMAC-SHA256 (Argon2id is not NIST approved)".into(),
194            ));
195        }
196        if self.cipher == CipherId::ChaCha20 {
197            return Err(Error::FipsViolation(
198                "FIPS mode requires AES-256-CTR (ChaCha20 is not NIST approved)".into(),
199            ));
200        }
201        Ok(())
202    }
203
204    /// Build a `Database` from a `TxnManager`, optionally creating or opening
205    /// an audit log. Centralizes the audit-log feature gating.
206    #[cfg(feature = "audit-log")]
207    fn finish(
208        self,
209        manager: TxnManager,
210        key_path: PathBuf,
211        file_id: u64,
212        audit_key: [u8; citadel_core::KEY_SIZE],
213        region_keys: Option<RegionWrapKeys>,
214        initial_event: Option<(crate::audit::AuditEventType, Vec<u8>)>,
215    ) -> Result<Database> {
216        use crate::audit;
217
218        let audit_log = if self.audit_config.enabled && !self.path.as_os_str().is_empty() {
219            let audit_path = audit::resolve_audit_path(&self.path);
220            let log = if audit_path.exists() {
221                audit::AuditLog::open_existing(&audit_path, file_id, audit_key, self.audit_config)?
222            } else {
223                audit::AuditLog::create(&audit_path, file_id, audit_key, self.audit_config)?
224            };
225            Some(log)
226        } else {
227            None
228        };
229
230        manager.set_secure_delete(self.secure_delete);
231        let db = Database::new(
232            manager,
233            self.path,
234            key_path,
235            file_id,
236            region_keys,
237            audit_log,
238        );
239
240        if let Some((event, detail)) = initial_event {
241            db.log_audit(event, &detail);
242        }
243
244        Ok(db)
245    }
246
247    #[cfg(not(feature = "audit-log"))]
248    fn finish(
249        self,
250        manager: TxnManager,
251        key_path: PathBuf,
252        file_id: u64,
253        _audit_key: [u8; citadel_core::KEY_SIZE],
254        region_keys: Option<RegionWrapKeys>,
255        _initial_event: Option<((), Vec<u8>)>,
256    ) -> Result<Database> {
257        manager.set_secure_delete(self.secure_delete);
258        Ok(Database::new(
259            manager,
260            self.path,
261            key_path,
262            file_id,
263            region_keys,
264        ))
265    }
266
267    /// Create a new database. Fails if the data file already exists.
268    #[cfg(not(target_arch = "wasm32"))]
269    pub fn create(self) -> Result<Database> {
270        #[cfg(feature = "fips")]
271        self.validate_fips()?;
272
273        let passphrase = self
274            .passphrase
275            .as_deref()
276            .ok_or(Error::PassphraseRequired)?;
277
278        let key_path = self.resolve_key_path();
279        let file_id: u64 = rand::random();
280
281        let (kf, keys, region_keys) = self.create_keys(passphrase, file_id)?;
282
283        durable::write_and_sync(&key_path, &kf.serialize())?;
284
285        let file = OpenOptions::new()
286            .read(true)
287            .write(true)
288            .create_new(true)
289            .open(&self.path)?;
290
291        file_lock::try_lock_exclusive(&file)?;
292
293        let dek_id = compute_dek_id(&keys.mac_key, &keys.dek);
294        let io = Self::create_page_io(file);
295
296        let manager = TxnManager::create_with_sync(
297            io,
298            keys.dek,
299            keys.mac_key,
300            kf.current_epoch,
301            file_id,
302            dek_id,
303            self.cache_size,
304            self.sync_mode,
305        )?;
306
307        #[cfg(feature = "audit-log")]
308        let event = {
309            let detail = vec![self.cipher as u8, self.kdf_algorithm as u8];
310            Some((crate::audit::AuditEventType::DatabaseCreated, detail))
311        };
312        #[cfg(not(feature = "audit-log"))]
313        let event: Option<((), Vec<u8>)> = None;
314
315        self.finish(
316            manager,
317            key_path,
318            file_id,
319            keys.audit_key,
320            region_keys,
321            event,
322        )
323    }
324
325    /// Create a new in-memory database (volatile, no file I/O).
326    ///
327    /// Data exists only for the lifetime of the returned `Database`.
328    /// Useful for testing, caching, and WASM environments.
329    pub fn create_in_memory(mut self) -> Result<Database> {
330        #[cfg(feature = "fips")]
331        self.validate_fips()?;
332
333        // Per-region cryptographic erasure needs a durable overwrite-in-place sidecar,
334        // which an in-memory database cannot provide; reject the combination up front.
335        if self.enable_region_keys {
336            return Err(Error::RegionKeysRequireFile);
337        }
338
339        let passphrase = self
340            .passphrase
341            .as_deref()
342            .ok_or(Error::PassphraseRequired)?;
343
344        let file_id: u64 = rand::random();
345
346        let (_kf, keys, region_keys) = self.create_keys(passphrase, file_id)?;
347
348        let dek_id = compute_dek_id(&keys.mac_key, &keys.dek);
349        let io: Box<dyn PageIO> = Box::new(citadel_io::memory_io::MemoryPageIO::new());
350
351        let manager = TxnManager::create_with_sync(
352            io,
353            keys.dek,
354            keys.mac_key,
355            1,
356            file_id,
357            dek_id,
358            self.cache_size,
359            self.sync_mode,
360        )?;
361
362        // Clear path so finish() won't create an audit log file on disk
363        self.path = PathBuf::new();
364        self.finish(
365            manager,
366            PathBuf::new(),
367            file_id,
368            keys.audit_key,
369            region_keys,
370            None,
371        )
372    }
373
374    /// Open an existing database. Fails if the data file does not exist.
375    #[cfg(not(target_arch = "wasm32"))]
376    pub fn open(self) -> Result<Database> {
377        let passphrase = self
378            .passphrase
379            .as_deref()
380            .ok_or(Error::PassphraseRequired)?;
381
382        let key_path = self.resolve_key_path();
383
384        let mut file = OpenOptions::new().read(true).write(true).open(&self.path)?;
385
386        file_lock::try_lock_exclusive(&file)?;
387
388        let mut header_buf = [0u8; FILE_HEADER_SIZE];
389        file.seek(SeekFrom::Start(0))?;
390        file.read_exact(&mut header_buf)?;
391        let header = FileHeader::deserialize(&header_buf)?;
392
393        let key_data = fs::read(&key_path)?;
394        if key_data.len() != KEY_FILE_SIZE {
395            return Err(Error::Io(std::io::Error::new(
396                std::io::ErrorKind::InvalidData,
397                "key file has incorrect size",
398            )));
399        }
400        let key_buf: [u8; KEY_FILE_SIZE] = key_data.try_into().unwrap();
401        let (kf, keys, region_keys) = self.open_keys(&key_buf, passphrase, header.file_id)?;
402
403        let dek_id = compute_dek_id(&keys.mac_key, &keys.dek);
404
405        let io = Self::create_page_io(file);
406
407        let manager = TxnManager::open_with_sync(
408            io,
409            keys.dek,
410            keys.mac_key,
411            kf.current_epoch,
412            self.cache_size,
413            self.sync_mode,
414        )?;
415
416        let slot = manager.current_slot();
417        if slot.dek_id != dek_id {
418            return Err(Error::BadPassphrase);
419        }
420
421        #[cfg(feature = "audit-log")]
422        let event = Some((crate::audit::AuditEventType::DatabaseOpened, vec![]));
423        #[cfg(not(feature = "audit-log"))]
424        let event: Option<((), Vec<u8>)> = None;
425
426        self.finish(
427            manager,
428            key_path,
429            header.file_id,
430            keys.audit_key,
431            region_keys,
432            event,
433        )
434    }
435
436    /// Create a key file, deriving region wrap keys only when `enable_region_keys`
437    /// is set. Returns the wrap keys to retain (`Some`) or `None` so the plaintext
438    /// path holds no region key material.
439    #[allow(clippy::type_complexity)]
440    fn create_keys(
441        &self,
442        passphrase: &[u8],
443        file_id: u64,
444    ) -> Result<(
445        citadel_crypto::key_manager::KeyFile,
446        citadel_crypto::hkdf_utils::DerivedKeys,
447        Option<RegionWrapKeys>,
448    )> {
449        let (m_cost, t_cost, p_cost) = self.resolve_kdf_params();
450        if self.enable_region_keys {
451            let (kf, keys, region) = create_key_file_with_region_keys(
452                passphrase,
453                file_id,
454                self.cipher,
455                self.kdf_algorithm,
456                m_cost,
457                t_cost,
458                p_cost,
459            )?;
460            Ok((kf, keys, Some(region)))
461        } else {
462            let (kf, keys) = create_key_file(
463                passphrase,
464                file_id,
465                self.cipher,
466                self.kdf_algorithm,
467                m_cost,
468                t_cost,
469                p_cost,
470            )?;
471            Ok((kf, keys, None))
472        }
473    }
474
475    /// Open a key file, deriving region wrap keys only when `enable_region_keys`.
476    #[cfg(not(target_arch = "wasm32"))]
477    #[allow(clippy::type_complexity)]
478    fn open_keys(
479        &self,
480        key_buf: &[u8; KEY_FILE_SIZE],
481        passphrase: &[u8],
482        expected_file_id: u64,
483    ) -> Result<(
484        citadel_crypto::key_manager::KeyFile,
485        citadel_crypto::hkdf_utils::DerivedKeys,
486        Option<RegionWrapKeys>,
487    )> {
488        if self.enable_region_keys {
489            let (kf, keys, region) =
490                open_key_file_with_region_keys(key_buf, passphrase, expected_file_id)?;
491            Ok((kf, keys, Some(region)))
492        } else {
493            let (kf, keys) = open_key_file(key_buf, passphrase, expected_file_id)?;
494            Ok((kf, keys, None))
495        }
496    }
497}