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