1use std::fs;
43use std::io::Write as _;
44use std::path::{Path, PathBuf};
45use std::sync::Arc;
46
47use hmac::{Hmac, Mac};
48use sha2::Sha256;
49use tempfile::NamedTempFile;
50use zeroize::Zeroize;
51
52use crate::encryption::{EncryptionEngine, EncryptionKey, derive_subkey, generate_key};
53use sochdb_core::{Result, SochDBError};
54
55type HmacSha256 = Hmac<Sha256>;
56
57const KEYRING_FORMAT_VERSION: u32 = 1;
59pub const KEYRING_FILE_NAME: &str = "keyring.json";
61const CANARY_TOKEN: &[u8] = b"sochdb-keyring-canary-v1";
63const INFO_WRAP: &[u8] = b"sochdb/keyring/wrap/v1";
65const INFO_MAC: &[u8] = b"sochdb/keyring/mac/v1";
66
67pub enum EncryptionState {
73 Plaintext,
74 Encrypted(ActiveEncryption),
75}
76
77impl std::fmt::Debug for EncryptionState {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 match self {
81 EncryptionState::Plaintext => write!(f, "EncryptionState::Plaintext"),
82 EncryptionState::Encrypted(a) => write!(
83 f,
84 "EncryptionState::Encrypted {{ db_uuid: {}, key_epoch: {} }}",
85 hex::encode(a.db_uuid),
86 a.key_epoch
87 ),
88 }
89 }
90}
91
92impl EncryptionState {
93 pub fn is_encrypted(&self) -> bool {
95 matches!(self, EncryptionState::Encrypted(_))
96 }
97
98 pub fn engine(&self) -> Arc<EncryptionEngine> {
100 match self {
101 EncryptionState::Plaintext => Arc::new(EncryptionEngine::disabled()),
102 EncryptionState::Encrypted(a) => a.engine.clone(),
103 }
104 }
105
106 pub fn db_uuid(&self) -> [u8; 16] {
108 match self {
109 EncryptionState::Plaintext => [0u8; 16],
110 EncryptionState::Encrypted(a) => a.db_uuid,
111 }
112 }
113
114 pub fn key_epoch(&self) -> u32 {
116 match self {
117 EncryptionState::Plaintext => 0,
118 EncryptionState::Encrypted(a) => a.key_epoch,
119 }
120 }
121}
122
123pub struct ActiveEncryption {
125 pub engine: Arc<EncryptionEngine>,
126 pub db_uuid: [u8; 16],
127 pub key_epoch: u32,
128}
129
130#[derive(serde::Serialize, serde::Deserialize)]
132struct KeyringFile {
133 format_version: u32,
134 encrypted: bool,
135 db_uuid: String,
136 kek_source_id: String,
137 key_epoch: u32,
138 salt: String,
139 wrapped_dek: String,
140 canary: String,
141 mac: String,
142}
143
144pub fn load_or_init(
157 db_dir: &Path,
158 kek: Option<&EncryptionKey>,
159 source_id: &str,
160 allow_create: bool,
161) -> Result<EncryptionState> {
162 let path = keyring_path(db_dir);
163
164 if path.exists() {
165 let file: KeyringFile = read_keyring(&path)?;
166 if file.format_version != KEYRING_FORMAT_VERSION {
167 return Err(SochDBError::Encryption(format!(
168 "unsupported keyring format version {} (expected {})",
169 file.format_version, KEYRING_FORMAT_VERSION
170 )));
171 }
172 let kek = kek.ok_or_else(|| {
180 SochDBError::Encryption(
181 "database has a keyring (encryption configured) but no \
182 encryption key was provided (set the KEK, e.g. \
183 SOCHDB_ENCRYPTION_KEY); refusing to open"
184 .to_string(),
185 )
186 })?;
187 verify_mac(&file, kek)?;
191 if !file.encrypted {
192 return Ok(EncryptionState::Plaintext);
195 }
196 open_encrypted(file, kek)
197 } else if let Some(kek) = kek {
198 if !allow_create {
199 return Err(SochDBError::Encryption(
200 "an encryption key was provided for a database that has no \
201 keyring (existing plaintext data must be migrated explicitly, \
202 not encrypted in place); refusing to open"
203 .to_string(),
204 ));
205 }
206 create_encrypted(db_dir, &path, kek, source_id)
207 } else {
208 Ok(EncryptionState::Plaintext)
209 }
210}
211
212fn keyring_path(db_dir: &Path) -> PathBuf {
213 db_dir.join(KEYRING_FILE_NAME)
214}
215
216fn read_keyring(path: &Path) -> Result<KeyringFile> {
217 let bytes = fs::read(path)?;
218 serde_json::from_slice(&bytes)
219 .map_err(|e| SochDBError::Encryption(format!("malformed keyring: {e}")))
220}
221
222fn mac_input(file: &KeyringFile) -> Vec<u8> {
225 let mut out = Vec::new();
226 let mut push = |b: &[u8]| {
227 out.extend_from_slice(&(b.len() as u32).to_le_bytes());
228 out.extend_from_slice(b);
229 };
230 push(&file.format_version.to_le_bytes());
231 push(&[file.encrypted as u8]);
232 push(file.db_uuid.as_bytes());
233 push(file.kek_source_id.as_bytes());
234 push(&file.key_epoch.to_le_bytes());
235 push(file.salt.as_bytes());
236 push(file.wrapped_dek.as_bytes());
237 push(file.canary.as_bytes());
238 out
239}
240
241fn compute_mac(mac_key: &EncryptionKey, file: &KeyringFile) -> Vec<u8> {
242 let mut mac = <HmacSha256 as Mac>::new_from_slice(mac_key.as_bytes())
243 .expect("HMAC accepts any key length");
244 mac.update(&mac_input(file));
245 mac.finalize().into_bytes().to_vec()
246}
247
248fn wrap_aad(db_uuid: &[u8; 16], epoch: u32, source_id: &str) -> Vec<u8> {
251 let mut aad = Vec::with_capacity(16 + 4 + source_id.len());
252 aad.extend_from_slice(db_uuid);
253 aad.extend_from_slice(&epoch.to_le_bytes());
254 aad.extend_from_slice(source_id.as_bytes());
255 aad
256}
257
258fn canary_aad(db_uuid: &[u8; 16], epoch: u32) -> Vec<u8> {
259 let mut aad = Vec::with_capacity(16 + 4);
260 aad.extend_from_slice(db_uuid);
261 aad.extend_from_slice(&epoch.to_le_bytes());
262 aad
263}
264
265fn create_encrypted(
266 db_dir: &Path,
267 path: &Path,
268 kek: &EncryptionKey,
269 source_id: &str,
270) -> Result<EncryptionState> {
271 let mut db_uuid = [0u8; 16];
272 {
273 use rand::RngCore;
274 rand::rngs::OsRng.fill_bytes(&mut db_uuid);
275 }
276 let mut salt = [0u8; 16];
277 {
278 use rand::RngCore;
279 rand::rngs::OsRng.fill_bytes(&mut salt);
280 }
281 let epoch: u32 = 0;
282
283 let dek = EncryptionKey::new(generate_key());
285
286 let wrap_key = derive_subkey(kek.as_bytes(), &salt, INFO_WRAP);
288 let wrap_engine = EncryptionEngine::from_key(&wrap_key);
289 let wrapped_dek =
290 wrap_engine.encrypt_with_aad(dek.as_bytes(), &wrap_aad(&db_uuid, epoch, source_id))?;
291
292 let dek_engine = EncryptionEngine::from_key(&dek);
294 let canary = dek_engine.encrypt_with_aad(CANARY_TOKEN, &canary_aad(&db_uuid, epoch))?;
295
296 let mut file = KeyringFile {
297 format_version: KEYRING_FORMAT_VERSION,
298 encrypted: true,
299 db_uuid: hex::encode(db_uuid),
300 kek_source_id: source_id.to_string(),
301 key_epoch: epoch,
302 salt: hex::encode(salt),
303 wrapped_dek: hex::encode(&wrapped_dek),
304 canary: hex::encode(&canary),
305 mac: String::new(),
306 };
307 let mac_key = derive_subkey(kek.as_bytes(), &salt, INFO_MAC);
308 file.mac = hex::encode(compute_mac(&mac_key, &file));
309
310 if write_keyring_noclobber(db_dir, path, &file)? {
318 Ok(EncryptionState::Encrypted(ActiveEncryption {
319 engine: Arc::new(dek_engine),
320 db_uuid,
321 key_epoch: epoch,
322 }))
323 } else {
324 let existing = read_keyring(path)?;
326 if existing.format_version != KEYRING_FORMAT_VERSION {
327 return Err(SochDBError::Encryption(format!(
328 "unsupported keyring format version {} (expected {})",
329 existing.format_version, KEYRING_FORMAT_VERSION
330 )));
331 }
332 verify_mac(&existing, kek)?;
333 open_encrypted(existing, kek)
334 }
335}
336
337fn verify_mac(file: &KeyringFile, kek: &EncryptionKey) -> Result<()> {
341 let salt = decode_fixed::<16>(&file.salt, "salt")?;
342 let mac_key = derive_subkey(kek.as_bytes(), &salt, INFO_MAC);
343 let actual = hex::decode(&file.mac)
344 .map_err(|_| SochDBError::Encryption("malformed keyring mac".into()))?;
345 let mut mac = <HmacSha256 as Mac>::new_from_slice(mac_key.as_bytes())
349 .expect("HMAC accepts any key length");
350 mac.update(&mac_input(file));
351 mac.verify_slice(&actual).map_err(|_| {
352 SochDBError::Encryption(
353 "keyring authentication failed: wrong encryption key or tampered \
354 keyring; refusing to open"
355 .to_string(),
356 )
357 })
358}
359
360fn open_encrypted(file: KeyringFile, kek: &EncryptionKey) -> Result<EncryptionState> {
361 let salt = decode_fixed::<16>(&file.salt, "salt")?;
363 let db_uuid = decode_fixed::<16>(&file.db_uuid, "db_uuid")?;
364 let epoch = file.key_epoch;
365
366 let wrap_key = derive_subkey(kek.as_bytes(), &salt, INFO_WRAP);
368 let wrap_engine = EncryptionEngine::from_key(&wrap_key);
369 let wrapped_dek = hex::decode(&file.wrapped_dek)
370 .map_err(|_| SochDBError::Encryption("malformed wrapped_dek".into()))?;
371 let mut dek_bytes = wrap_engine
372 .decrypt_with_aad(
373 &wrapped_dek,
374 &wrap_aad(&db_uuid, epoch, &file.kek_source_id),
375 )
376 .map_err(|_| {
377 SochDBError::Encryption(
378 "failed to unwrap data key: wrong encryption key; refusing to open".into(),
379 )
380 })?;
381 if dek_bytes.len() != 32 {
382 dek_bytes.zeroize();
383 return Err(SochDBError::Encryption(
384 "unwrapped DEK is not 32 bytes".into(),
385 ));
386 }
387 let mut dek_arr = [0u8; 32];
391 dek_arr.copy_from_slice(&dek_bytes);
392 dek_bytes.zeroize();
393 let dek = EncryptionKey::new(dek_arr);
394 dek_arr.zeroize();
395
396 let dek_engine = EncryptionEngine::from_key(&dek);
398 let canary = hex::decode(&file.canary)
399 .map_err(|_| SochDBError::Encryption("malformed canary".into()))?;
400 let token = dek_engine
401 .decrypt_with_aad(&canary, &canary_aad(&db_uuid, epoch))
402 .map_err(|_| {
403 SochDBError::Encryption(
404 "canary decryption failed: wrong encryption key; refusing to open".into(),
405 )
406 })?;
407 if token != CANARY_TOKEN {
408 return Err(SochDBError::Encryption(
409 "canary token mismatch; refusing to open".into(),
410 ));
411 }
412
413 Ok(EncryptionState::Encrypted(ActiveEncryption {
414 engine: Arc::new(dek_engine),
415 db_uuid,
416 key_epoch: epoch,
417 }))
418}
419
420fn decode_fixed<const N: usize>(hexstr: &str, what: &str) -> Result<[u8; N]> {
421 let v = hex::decode(hexstr)
422 .map_err(|_| SochDBError::Encryption(format!("malformed keyring {what}")))?;
423 if v.len() != N {
424 return Err(SochDBError::Encryption(format!(
425 "keyring {what} wrong length: {} != {N}",
426 v.len()
427 )));
428 }
429 let mut a = [0u8; N];
430 a.copy_from_slice(&v);
431 Ok(a)
432}
433
434fn write_keyring_noclobber(db_dir: &Path, path: &Path, file: &KeyringFile) -> Result<bool> {
441 fs::create_dir_all(db_dir)?;
442 let json = serde_json::to_vec_pretty(file)
443 .map_err(|e| SochDBError::Encryption(format!("serialize keyring: {e}")))?;
444
445 let mut tmp = NamedTempFile::new_in(db_dir)?;
446 tmp.write_all(&json)?;
447 tmp.as_file().sync_all()?;
448
449 match tmp.persist_noclobber(path) {
453 Ok(f) => {
454 f.sync_all()?;
455 fsync_dir(db_dir);
456 Ok(true)
457 }
458 Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
459 Err(e) => Err(SochDBError::Encryption(format!(
460 "failed to publish keyring: {}",
461 e.error
462 ))),
463 }
464}
465
466fn fsync_dir(db_dir: &Path) {
470 #[cfg(unix)]
471 {
472 if let Ok(dir) = fs::File::open(db_dir) {
473 let _ = dir.sync_all();
474 }
475 }
476 #[cfg(not(unix))]
477 {
478 let _ = db_dir;
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use tempfile::tempdir;
486
487 fn kek(seed: u8) -> EncryptionKey {
488 EncryptionKey::new([seed; 32])
489 }
490
491 #[test]
492 fn plaintext_when_no_key_and_no_file() {
493 let dir = tempdir().unwrap();
494 let st = load_or_init(dir.path(), None, "test", true).unwrap();
495 assert!(!st.is_encrypted());
496 assert!(!dir.path().join(KEYRING_FILE_NAME).exists());
497 }
498
499 #[test]
500 fn create_then_reopen_roundtrips_dek() {
501 let dir = tempdir().unwrap();
502 let st = load_or_init(dir.path(), Some(&kek(7)), "env", true).unwrap();
503 assert!(st.is_encrypted());
504 let uuid1 = st.db_uuid();
505 let ct = st.engine().encrypt(b"secret").unwrap();
507 assert_ne!(ct, b"secret");
508
509 let st2 = load_or_init(dir.path(), Some(&kek(7)), "env", false).unwrap();
511 assert!(st2.is_encrypted());
512 assert_eq!(st2.db_uuid(), uuid1);
513 assert_eq!(st2.engine().decrypt(&ct).unwrap(), b"secret");
514 }
515
516 #[test]
517 fn reopen_with_wrong_key_fails_closed() {
518 let dir = tempdir().unwrap();
519 load_or_init(dir.path(), Some(&kek(1)), "env", true).unwrap();
520 let err = load_or_init(dir.path(), Some(&kek(2)), "env", false).unwrap_err();
521 assert!(matches!(err, SochDBError::Encryption(_)));
523 }
524
525 #[test]
526 fn reopen_encrypted_without_key_fails_closed() {
527 let dir = tempdir().unwrap();
528 load_or_init(dir.path(), Some(&kek(1)), "env", true).unwrap();
529 let err = load_or_init(dir.path(), None, "env", true).unwrap_err();
530 assert!(matches!(err, SochDBError::Encryption(_)));
531 }
532
533 #[test]
534 fn forging_encrypted_false_is_rejected_by_mac() {
535 let dir = tempdir().unwrap();
536 load_or_init(dir.path(), Some(&kek(9)), "env", true).unwrap();
537 let path = dir.path().join(KEYRING_FILE_NAME);
538
539 let mut file: KeyringFile = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
541 file.encrypted = false;
542 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
543
544 let err = load_or_init(dir.path(), Some(&kek(9)), "env", false).unwrap_err();
548 assert!(matches!(err, SochDBError::Encryption(_)));
549 }
550
551 #[test]
552 fn keyring_present_but_no_key_fails_even_if_flag_says_plaintext() {
553 let dir = tempdir().unwrap();
554 load_or_init(dir.path(), Some(&kek(4)), "env", true).unwrap();
555 let path = dir.path().join(KEYRING_FILE_NAME);
556 let mut file: KeyringFile = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
557 file.encrypted = false; fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
559
560 let err = load_or_init(dir.path(), None, "env", false).unwrap_err();
562 assert!(matches!(err, SochDBError::Encryption(_)));
563 }
564
565 #[test]
566 fn tampering_authenticated_field_is_rejected() {
567 let dir = tempdir().unwrap();
568 load_or_init(dir.path(), Some(&kek(5)), "env", true).unwrap();
569 let path = dir.path().join(KEYRING_FILE_NAME);
570
571 let mut file: KeyringFile = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
572 file.key_epoch = 999;
574 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
575
576 let err = load_or_init(dir.path(), Some(&kek(5)), "env", false).unwrap_err();
577 assert!(matches!(err, SochDBError::Encryption(_)));
578 }
579
580 #[test]
581 fn concurrent_first_open_converges_on_single_dek() {
582 use std::sync::{Arc as StdArc, Barrier};
583 let dir = tempdir().unwrap();
584 let path = StdArc::new(dir.path().to_path_buf());
585 let n = 8;
590 let barrier = StdArc::new(Barrier::new(n));
591 let handles: Vec<_> = (0..n)
592 .map(|i| {
593 let p = path.clone();
594 let b = barrier.clone();
595 std::thread::spawn(move || {
596 b.wait();
597 let k = kek(42);
598 let st = load_or_init(&p, Some(&k), &format!("t{i}"), true).unwrap();
599 st.db_uuid()
600 })
601 })
602 .collect();
603 let uuids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
604 let first = uuids[0];
605 assert!(
606 uuids.iter().all(|u| *u == first),
607 "concurrent creators diverged onto multiple DEKs: {uuids:?}"
608 );
609 }
610
611 #[test]
612 fn key_provided_for_existing_plaintext_db_without_create_fails() {
613 let dir = tempdir().unwrap();
614 fs::write(dir.path().join("wal.log"), b"legacy").unwrap();
616 let err = load_or_init(dir.path(), Some(&kek(3)), "env", false).unwrap_err();
617 assert!(matches!(err, SochDBError::Encryption(_)));
618 }
619}