Skip to main content

sochdb_storage/
keyring.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4
5//! # Keyring — KEK/DEK envelope for data-at-rest encryption (Task 3B)
6//!
7//! The keyring is the per-database key-management substrate that sits in front
8//! of [`crate::encryption::EncryptionEngine`]. It exists so that:
9//!
10//! - The operator-supplied secret (the **KEK**, e.g. `SOCHDB_ENCRYPTION_KEY` or
11//!   an embedded `ConnectionConfig.encryption` key) is **never** used verbatim
12//!   as the cipher key. Instead a random per-DB **DEK** is generated, and the
13//!   KEK only wraps it. Rotating the KEK is then a cheap re-wrap — it does NOT
14//!   require re-encrypting any data.
15//! - A wrong or missing key is caught **fail-closed at open**, before a single
16//!   WAL byte is read, via an authenticated descriptor MAC and a DEK canary —
17//!   never silently degrading to a plaintext read or an "empty" database.
18//! - A `key_epoch` is reserved from the first encrypted byte, so future DEK
19//!   rotation is expressible on disk without a format break.
20//!
21//! ## On-disk descriptor (`<db_dir>/keyring.json`)
22//!
23//! The **presence** of this file with `encrypted=true` is the source of truth
24//! that a database is encrypted. A plaintext DB has no keyring file at all
25//! (preserving byte-compatibility with pre-3B binaries). All byte fields are
26//! hex-encoded. The whole descriptor is authenticated by `mac` =
27//! HMAC-SHA256(HKDF(KEK, salt, "keyring-mac"), canonical-fields), so an attacker
28//! or a bad rollback cannot flip `encrypted` to `false` to force a downgrade.
29//!
30//! ```text
31//! { format_version, encrypted, db_uuid, kek_source_id, key_epoch,
32//!   salt, wrapped_dek, canary, mac }
33//! ```
34//!
35//! - `wrapped_dek` = AEAD(HKDF(KEK,salt,"wrap")).encrypt(DEK, aad=wrap_aad)
36//! - `canary`      = AEAD(DEK).encrypt(CANARY_TOKEN, aad=canary_aad)
37//!
38//! On open we (1) verify the MAC with the KEK, (2) unwrap the DEK, (3) decrypt
39//! the canary with the DEK. Any failure ⇒ hard error (wrong/missing KEK or
40//! tampering). Only after all three pass is the WAL touched.
41
42use 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
57/// Current keyring descriptor format version.
58const KEYRING_FORMAT_VERSION: u32 = 1;
59/// Keyring file name within the database directory.
60pub const KEYRING_FILE_NAME: &str = "keyring.json";
61/// Fixed plaintext token sealed under the DEK to detect a wrong key at open.
62const CANARY_TOKEN: &[u8] = b"sochdb-keyring-canary-v1";
63/// HKDF info labels — keep these stable; they are part of the on-disk contract.
64const INFO_WRAP: &[u8] = b"sochdb/keyring/wrap/v1";
65const INFO_MAC: &[u8] = b"sochdb/keyring/mac/v1";
66
67/// The resolved encryption state for an opened database.
68///
69/// `Plaintext` carries a disabled engine (identity passthrough, byte-identical
70/// legacy frames). `Encrypted` carries the live DEK-backed engine plus the
71/// `db_uuid` and `key_epoch` that the WAL binds into every record's AAD.
72pub 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        // Deliberately does NOT print key material.
80        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    /// Whether at-rest encryption is active for this database.
94    pub fn is_encrypted(&self) -> bool {
95        matches!(self, EncryptionState::Encrypted(_))
96    }
97
98    /// The engine to hand to the WAL (disabled passthrough if plaintext).
99    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    /// 16-byte DB identity bound into WAL AAD (all-zero for plaintext, unused).
107    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    /// Active DEK epoch (0 for plaintext, unused).
115    pub fn key_epoch(&self) -> u32 {
116        match self {
117            EncryptionState::Plaintext => 0,
118            EncryptionState::Encrypted(a) => a.key_epoch,
119        }
120    }
121}
122
123/// Live encryption context for an encrypted database.
124pub struct ActiveEncryption {
125    pub engine: Arc<EncryptionEngine>,
126    pub db_uuid: [u8; 16],
127    pub key_epoch: u32,
128}
129
130/// On-disk keyring descriptor (hex-encoded byte fields).
131#[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
144/// Resolve the encryption state for a database directory.
145///
146/// Contract (the file's presence + `encrypted` flag is the source of truth):
147/// - **keyring present, `encrypted=true`**: `kek` is REQUIRED. We verify the
148///   descriptor MAC, unwrap the DEK, and check the canary. Any failure (wrong
149///   key, missing key, tamper) is a hard, fail-closed error — we never fall
150///   back to a disabled engine. Returns `Encrypted`.
151/// - **keyring present, `encrypted=false`**: plaintext DB. Returns `Plaintext`.
152/// - **keyring absent + `kek = Some`**: a *new* encrypted DB. Generates a DEK,
153///   wraps it, writes the keyring atomically. `allow_create` MUST be true (the
154///   caller asserts this is not an existing plaintext DB). Returns `Encrypted`.
155/// - **keyring absent + `kek = None`**: legacy/plaintext DB. Returns `Plaintext`.
156pub 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        // A keyring file is ONLY ever written for an encrypted database, so its
173        // mere presence means a KEK is required. Resolve it fail-closed BEFORE
174        // honoring the `encrypted` flag — otherwise an attacker could flip
175        // `encrypted` to false (or drop the env key) to force a plaintext
176        // downgrade. The descriptor MAC (verified inside `open_encrypted`, and
177        // re-checked below for the plaintext-marker case) cannot be recomputed
178        // without the KEK, so a forged `encrypted=false` is rejected.
179        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        // Authenticate the descriptor with the KEK first. This rejects a forged
188        // `encrypted=false` downgrade, since the MAC covers the `encrypted`
189        // field and cannot be reforged without the KEK.
190        verify_mac(&file, kek)?;
191        if !file.encrypted {
192            // MAC-authenticated plaintext marker (not written by current code,
193            // but honored if it ever authenticates — defensive forward-compat).
194            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
222/// Build the canonical, deterministic byte string the MAC authenticates.
223/// Length-prefixed fields so no two distinct descriptors collide.
224fn 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
248/// AAD binding the wrapped DEK to this DB identity + epoch + KEK source, so a
249/// wrapped DEK cannot be spliced into a different DB/epoch under a shared KEK.
250fn 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    // Random per-DB DEK; this is what actually encrypts data.
284    let dek = EncryptionKey::new(generate_key());
285
286    // Wrap the DEK under a wrapping key derived from the KEK.
287    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    // Seal a canary under the DEK so a wrong key is detected at open.
293    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    // Publish the keyring EXCLUSIVELY. The concurrent (multi-process) open path
311    // holds no exclusive file lock, so two processes cold-starting the same fresh
312    // encrypted DB could otherwise BOTH generate a DEK and last-writer-wins clobber
313    // the keyring — orphaning the loser's DEK and silently losing all data it wrote
314    // under it. Exclusive create makes exactly one creator win; the loser ADOPTS
315    // the winner's keyring (re-derives the winner's DEK under the shared KEK), so
316    // both processes converge on a single DEK.
317    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        // Lost the create race: adopt the winner's keyring with our KEK.
325        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
337/// Verify the descriptor MAC with the KEK. A wrong KEK or any tampering of an
338/// authenticated field (e.g. `encrypted` flipped to false, epoch altered) fails
339/// here — the MAC cannot be recomputed without the KEK.
340fn 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    // Use HMAC's own vetted constant-time tag verification rather than a
346    // hand-rolled compare, so the constant-time property is not at the mercy of
347    // optimizer transforms.
348    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    // MAC is already verified by the caller (load_or_init).
362    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    // Unwrap the DEK.
367    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    // Move the plaintext DEK into the zeroize-on-drop wrapper and wipe the
388    // transient heap/stack copies the AEAD left behind — the DEK decrypts ALL
389    // data, so it must not linger in freed memory / swap / core dumps.
390    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    // Canary check: prove the DEK actually decrypts data before touching WAL.
397    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
434/// Persist the keyring with an EXCLUSIVE (no-clobber) publish: write a temp file,
435/// fsync it, then atomically link it into place only if the target does not yet
436/// exist. Returns `Ok(true)` if this call created the keyring, `Ok(false)` if
437/// another creator won the race (target already existed). Crash-safe (the temp is
438/// fully fsynced before it is linked) AND race-safe (the final create is atomic +
439/// exclusive, so two concurrent creators cannot clobber each other).
440fn 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    // `persist_noclobber` performs an atomic exclusive create of the final path
450    // (link/rename that fails if it already exists), so it does not clobber a
451    // keyring another process created concurrently.
452    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
466/// fsync the directory so a create/link is durable. On Unix a real I/O error
467/// must surface; on other platforms opening a directory handle isn't supported,
468/// so this is best-effort there.
469fn 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        // Engine actually encrypts.
506        let ct = st.engine().encrypt(b"secret").unwrap();
507        assert_ne!(ct, b"secret");
508
509        // Reopen with the SAME kek -> same DEK -> can decrypt the ciphertext.
510        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        // Must be a hard encryption error, NOT a silent plaintext/empty open.
522        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        // Attacker flips encrypted -> false to force a plaintext downgrade.
540        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        // MAC is verified BEFORE the encrypted flag is honored, and the MAC
545        // covers `encrypted`, so the forgery is rejected fail-closed — never a
546        // silent plaintext/empty open.
547        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; // forged
558        fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
559
560        // No key supplied + keyring present ⇒ refuse (presence implies encryption).
561        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        // Tamper an authenticated field while keeping encrypted=true.
573        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        // All threads cold-start the SAME fresh encrypted dir simultaneously with
586        // the SAME KEK (the multi-process scenario). They MUST converge on one
587        // keyring/DEK (one creator wins, the rest adopt it) rather than each
588        // minting an independent DEK that last-writer-wins would orphan.
589        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        // Simulate an existing plaintext DB: a wal.log but no keyring.
615        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}