Skip to main content

veil_vault/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::fs::OpenOptions;
5use std::num::NonZeroUsize;
6use std::path::{Path, PathBuf};
7use std::sync::{Mutex, MutexGuard};
8use std::time::Duration;
9
10use aes_gcm::aead::{Aead, KeyInit, Payload};
11use aes_gcm::{Aes256Gcm, Nonce};
12use hkdf::Hkdf;
13use hmac::{Hmac, Mac};
14use lru::LruCache;
15use rusqlite::{params, Connection, OptionalExtension};
16use sha2::Sha256;
17use veil_engine::{EntityKind, ScopeId, Vault};
18
19mod managed;
20pub use managed::{ManagedVault, RamVault, VaultInfo};
21
22/// This crate's version as compiled in, such as `"0.2.0"`, so an application
23/// can show which release of it is running.
24pub const VERSION: &str = env!("CARGO_PKG_VERSION");
25
26/// The 256-bit key everything in a vault is derived from.
27pub type MasterKey = [u8; 32];
28
29/// Why a vault operation failed.
30#[derive(Debug, thiserror::Error)]
31pub enum Error {
32    /// The SQLite database failed.
33    #[error("database: {0}")]
34    Db(#[from] rusqlite::Error),
35    /// The key file couldn't be read or written.
36    #[error("key file {path}: {error}")]
37    KeyFile {
38        /// The key file.
39        path: PathBuf,
40        /// What went wrong.
41        error: std::io::Error,
42    },
43    /// The key file isn't 32 bytes or 64 hex characters.
44    #[error("key file {0} must hold 32 bytes or 64 hex characters")]
45    KeyFormat(PathBuf),
46    /// The OS keychain failed.
47    #[error("keychain: {0}")]
48    Keychain(String),
49    /// The vault was created with another key.
50    #[error("the vault was created with a different key; refusing to open it")]
51    WrongKey,
52    /// An entry didn't decrypt: it was damaged, or moved to another slot.
53    #[error("decryption failed for a vault entry")]
54    Decrypt,
55    /// A file operation failed.
56    #[error("{0}")]
57    Io(#[from] std::io::Error),
58    /// A thread panicked while holding the vault's lock.
59    #[error("internal lock poisoned")]
60    Poisoned,
61}
62
63impl From<Error> for veil_engine::VaultError {
64    fn from(e: Error) -> Self {
65        veil_engine::VaultError::new(e)
66    }
67}
68
69/// Where the master key comes from.
70#[derive(Clone, Debug)]
71pub enum KeySource {
72    /// The OS keychain: Keychain on macOS, Credential Manager on Windows,
73    /// Secret Service (GNOME Keyring, KWallet) on Linux.
74    Keychain {
75        /// The item's service name, which the OS shows when it asks for access.
76        service: String,
77        /// The item's account name.
78        account: String,
79    },
80    /// A file holding the key, for servers and containers. Created with
81    /// owner-only permissions if missing.
82    File(PathBuf),
83}
84
85/// The keychain item's service name, shown by the OS when it asks for access.
86pub const KEYCHAIN_SERVICE: &str = "Veil";
87
88impl KeySource {
89    /// The keychain item Veil uses.
90    pub fn default_keychain() -> Self {
91        KeySource::Keychain { service: KEYCHAIN_SERVICE.into(), account: "vault-master-key".into() }
92    }
93
94    /// Reads the key, creating and storing a new random one on first use.
95    pub fn load_or_create(&self) -> Result<MasterKey, Error> {
96        match self {
97            KeySource::Keychain { service, account } => {
98                let entry = keyring::Entry::new(service, account).map_err(|e| Error::Keychain(e.to_string()))?;
99                match entry.get_password() {
100                    Ok(hex) => parse_hex(hex.trim()).ok_or_else(|| Error::Keychain("stored key is malformed".into())),
101                    Err(keyring::Error::NoEntry) => {
102                        let key = random_key();
103                        entry.set_password(&to_hex(&key)).map_err(|e| Error::Keychain(e.to_string()))?;
104                        Ok(key)
105                    }
106                    Err(e) => Err(Error::Keychain(e.to_string())),
107                }
108            }
109            KeySource::File(path) => {
110                let io = |error| Error::KeyFile { path: path.clone(), error };
111                match std::fs::read(path) {
112                    Ok(bytes) => {
113                        if let Ok(key) = <MasterKey>::try_from(bytes.as_slice()) {
114                            return Ok(key);
115                        }
116                        std::str::from_utf8(&bytes)
117                            .ok()
118                            .and_then(|s| parse_hex(s.trim()))
119                            .ok_or_else(|| Error::KeyFormat(path.clone()))
120                    }
121                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
122                        let key = random_key();
123                        if let Some(dir) = path.parent() {
124                            std::fs::create_dir_all(dir).map_err(io)?;
125                        }
126                        let mut f = private_file(path).map_err(io)?;
127                        std::io::Write::write_all(&mut f, to_hex(&key).as_bytes()).map_err(io)?;
128                        Ok(key)
129                    }
130                    Err(e) => Err(io(e)),
131                }
132            }
133        }
134    }
135}
136
137struct Keys {
138    enc: Aes256Gcm,
139    mac: [u8; 32],
140    scope: [u8; 32],
141}
142
143impl Keys {
144    fn derive(master: &MasterKey) -> Self {
145        let hk = Hkdf::<Sha256>::new(None, master);
146        let mut enc = [0u8; 32];
147        let mut mac = [0u8; 32];
148        let mut scope = [0u8; 32];
149        hk.expand(b"veil vault encryption v1", &mut enc).expect("32 bytes is a valid HKDF length");
150        hk.expand(b"veil vault lookup v1", &mut mac).expect("32 bytes is a valid HKDF length");
151        hk.expand(b"veil scope v1", &mut scope).expect("32 bytes is a valid HKDF length");
152        Keys { enc: Aes256Gcm::new_from_slice(&enc).expect("32-byte key"), mac, scope }
153    }
154
155    fn mac(&self, parts: &[&[u8]]) -> Vec<u8> {
156        let mut m = <Hmac<Sha256> as Mac>::new_from_slice(&self.mac).expect("HMAC accepts any key length");
157        for p in parts {
158            m.update(&(p.len() as u64).to_le_bytes());
159            m.update(p);
160        }
161        m.finalize().into_bytes().to_vec()
162    }
163}
164
165type ValueKey = (ScopeId, EntityKind, Vec<u8>);
166type NumberKey = (ScopeId, EntityKind, u64);
167
168struct Caches {
169    by_value: LruCache<ValueKey, (u64, i64)>,
170    by_number: LruCache<NumberKey, (String, i64)>,
171}
172
173/// How often `last_used_at` is written for an entry that keeps being used.
174const TOUCH_INTERVAL: i64 = 3600;
175
176/// An encrypted vault in a SQLite file. See the crate documentation for how
177/// values are protected.
178pub struct SqliteVault {
179    conn: Mutex<Connection>,
180    caches: Mutex<Caches>,
181    keys: Keys,
182    ttl: Duration,
183    path: PathBuf,
184}
185
186impl SqliteVault {
187    /// Opens or creates the vault at `path`. Fails if it was created with a
188    /// different key, so a wrong key can never silently start a fresh mapping.
189    pub fn open(path: &Path, master: &MasterKey, ttl: Duration) -> Result<Self, Error> {
190        if let Some(dir) = path.parent() {
191            std::fs::create_dir_all(dir)?;
192        }
193        if !path.exists() {
194            private_file(path)?;
195        }
196        let conn = Connection::open(path)?;
197        conn.execute_batch(
198            "PRAGMA journal_mode = WAL;
199             PRAGMA synchronous = NORMAL;
200             PRAGMA secure_delete = ON;
201             CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB NOT NULL);
202             CREATE TABLE IF NOT EXISTS entries (
203               scope TEXT NOT NULL, kind TEXT NOT NULL, n INTEGER NOT NULL,
204               value_mac BLOB NOT NULL, value_ct BLOB NOT NULL,
205               created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL,
206               PRIMARY KEY (scope, kind, value_mac));
207             CREATE UNIQUE INDEX IF NOT EXISTS entries_n ON entries (scope, kind, n);
208             CREATE INDEX IF NOT EXISTS entries_used ON entries (last_used_at);
209             CREATE TABLE IF NOT EXISTS counters (
210               scope TEXT NOT NULL, kind TEXT NOT NULL, last INTEGER NOT NULL, PRIMARY KEY (scope, kind));
211             CREATE TABLE IF NOT EXISTS responses (
212               id_mac BLOB PRIMARY KEY, scope TEXT NOT NULL, created_at INTEGER NOT NULL);",
213        )?;
214        let keys = Keys::derive(master);
215        let check = keys.mac(&[b"veil key check"]);
216        let stored: Option<Vec<u8>> =
217            conn.query_row("SELECT value FROM meta WHERE key = 'key_check'", [], |r| r.get(0)).optional()?;
218        match stored {
219            Some(s) if s != check => return Err(Error::WrongKey),
220            Some(_) => {}
221            None => {
222                conn.execute("INSERT INTO meta (key, value) VALUES ('key_check', ?1)", params![check])?;
223            }
224        }
225        let cap = NonZeroUsize::new(4096).expect("non-zero");
226        Ok(SqliteVault {
227            conn: Mutex::new(conn),
228            caches: Mutex::new(Caches { by_value: LruCache::new(cap), by_number: LruCache::new(cap) }),
229            keys,
230            ttl,
231            path: path.to_owned(),
232        })
233    }
234
235    /// The database file.
236    pub fn path(&self) -> &Path {
237        &self.path
238    }
239
240    fn conn(&self) -> Result<MutexGuard<'_, Connection>, Error> {
241        self.conn.lock().map_err(|_| Error::Poisoned)
242    }
243
244    fn caches(&self) -> Result<MutexGuard<'_, Caches>, Error> {
245        self.caches.lock().map_err(|_| Error::Poisoned)
246    }
247
248    fn aad(scope: &ScopeId, kind: &EntityKind, n: u64) -> Vec<u8> {
249        format!("{}\0{}\0{}", scope.as_str(), kind.as_str(), n).into_bytes()
250    }
251
252    fn encrypt(&self, scope: &ScopeId, kind: &EntityKind, n: u64, value: &str) -> Result<Vec<u8>, Error> {
253        let mut nonce = [0u8; 12];
254        getrandom::fill(&mut nonce).map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?;
255        let aad = Self::aad(scope, kind, n);
256        let ct = self
257            .keys
258            .enc
259            .encrypt(Nonce::from_slice(&nonce), Payload { msg: value.as_bytes(), aad: &aad })
260            .map_err(|_| Error::Decrypt)?;
261        Ok([nonce.as_slice(), &ct].concat())
262    }
263
264    fn decrypt(&self, scope: &ScopeId, kind: &EntityKind, n: u64, blob: &[u8]) -> Result<String, Error> {
265        if blob.len() < 12 {
266            return Err(Error::Decrypt);
267        }
268        let aad = Self::aad(scope, kind, n);
269        let plain = self
270            .keys
271            .enc
272            .decrypt(Nonce::from_slice(&blob[..12]), Payload { msg: &blob[12..], aad: &aad })
273            .map_err(|_| Error::Decrypt)?;
274        String::from_utf8(plain).map_err(|_| Error::Decrypt)
275    }
276
277    fn touch(&self, conn: &Connection, scope: &ScopeId, kind: &EntityKind, n: u64, now: i64) -> Result<(), Error> {
278        conn.execute(
279            "UPDATE entries SET last_used_at = ?4 WHERE scope = ?1 AND kind = ?2 AND n = ?3 AND last_used_at < ?4 - ?5",
280            params![scope.as_str(), kind.as_str(), n as i64, now, TOUCH_INTERVAL],
281        )?;
282        Ok(())
283    }
284
285    fn get_or_create_inner(&self, scope: &ScopeId, kind: &EntityKind, value: &str) -> Result<u64, Error> {
286        let mac = self.keys.mac(&[scope.as_str().as_bytes(), kind.as_str().as_bytes(), value.as_bytes()]);
287        let key = (scope.clone(), kind.clone(), mac.clone());
288        let now = now();
289        if let Some(&(n, touched)) = self.caches()?.by_value.get(&key) {
290            if now - touched < TOUCH_INTERVAL {
291                return Ok(n);
292            }
293        }
294        let mut conn = self.conn()?;
295        let tx = conn.transaction()?;
296        let existing: Option<i64> = tx
297            .query_row(
298                "SELECT n FROM entries WHERE scope = ?1 AND kind = ?2 AND value_mac = ?3",
299                params![scope.as_str(), kind.as_str(), mac],
300                |r| r.get(0),
301            )
302            .optional()?;
303        let n = match existing {
304            Some(n) => {
305                self.touch(&tx, scope, kind, n as u64, now)?;
306                n as u64
307            }
308            None => {
309                let n: i64 = tx.query_row(
310                    "INSERT INTO counters (scope, kind, last) VALUES (?1, ?2, 1)
311                     ON CONFLICT (scope, kind) DO UPDATE SET last = last + 1 RETURNING last",
312                    params![scope.as_str(), kind.as_str()],
313                    |r| r.get(0),
314                )?;
315                let ct = self.encrypt(scope, kind, n as u64, value)?;
316                tx.execute(
317                    "INSERT INTO entries (scope, kind, n, value_mac, value_ct, created_at, last_used_at)
318                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
319                    params![scope.as_str(), kind.as_str(), n, mac, ct, now],
320                )?;
321                n as u64
322            }
323        };
324        tx.commit()?;
325        let mut caches = self.caches()?;
326        caches.by_value.put(key, (n, now));
327        caches.by_number.put((scope.clone(), kind.clone(), n), (value.to_owned(), now));
328        Ok(n)
329    }
330
331    fn lookup_inner(&self, scope: &ScopeId, kind: &EntityKind, n: u64) -> Result<Option<String>, Error> {
332        let key = (scope.clone(), kind.clone(), n);
333        let now = now();
334        if let Some((v, touched)) = self.caches()?.by_number.get(&key) {
335            if now - touched < TOUCH_INTERVAL {
336                return Ok(Some(v.clone()));
337            }
338        }
339        let conn = self.conn()?;
340        let blob: Option<Vec<u8>> = conn
341            .query_row(
342                "SELECT value_ct FROM entries WHERE scope = ?1 AND kind = ?2 AND n = ?3",
343                params![scope.as_str(), kind.as_str(), n as i64],
344                |r| r.get(0),
345            )
346            .optional()?;
347        let Some(blob) = blob else {
348            return Ok(None);
349        };
350        let value = self.decrypt(scope, kind, n, &blob)?;
351        self.touch(&conn, scope, kind, n, now)?;
352        self.caches()?.by_number.put(key, (value.clone(), now));
353        Ok(Some(value))
354    }
355
356    fn adopt_inner(&self, from: &ScopeId, into: &ScopeId) -> Result<bool, Error> {
357        if from == into {
358            return Ok(false);
359        }
360        let mut conn = self.conn()?;
361        let tx = conn.transaction()?;
362        let used: Option<i64> = tx
363            .query_row("SELECT 1 FROM counters WHERE scope = ?1 LIMIT 1", params![into.as_str()], |r| r.get(0))
364            .optional()?;
365        if used.is_some() {
366            return Ok(false);
367        }
368        tx.execute(
369            "INSERT INTO counters (scope, kind, last) SELECT ?2, kind, last FROM counters WHERE scope = ?1",
370            params![from.as_str(), into.as_str()],
371        )?;
372        let rows: Vec<(String, i64, Vec<u8>)> = {
373            let mut stmt = tx.prepare("SELECT kind, n, value_ct FROM entries WHERE scope = ?1")?;
374            let rows = stmt.query_map(params![from.as_str()], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
375            rows.collect::<Result<_, _>>()?
376        };
377        let now = now();
378        for (kind, n, blob) in rows {
379            let kind = EntityKind::new(&kind).map_err(|_| Error::Decrypt)?;
380            // Ciphertexts are bound to their scope and number, so each is sealed again.
381            let value = self.decrypt(from, &kind, n as u64, &blob)?;
382            let mac = self.keys.mac(&[into.as_str().as_bytes(), kind.as_str().as_bytes(), value.as_bytes()]);
383            let ct = self.encrypt(into, &kind, n as u64, &value)?;
384            tx.execute(
385                "INSERT INTO entries (scope, kind, n, value_mac, value_ct, created_at, last_used_at)
386                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
387                params![into.as_str(), kind.as_str(), n, mac, ct, now],
388            )?;
389        }
390        tx.commit()?;
391        Ok(true)
392    }
393
394    fn clear_caches(&self) -> Result<(), Error> {
395        let mut c = self.caches()?;
396        c.by_value.clear();
397        c.by_number.clear();
398        Ok(())
399    }
400
401    fn purge_before(&self, cutoff: i64) -> Result<u64, Error> {
402        let conn = self.conn()?;
403        let n = conn.execute("DELETE FROM entries WHERE last_used_at < ?1", params![cutoff])?;
404        conn.execute("DELETE FROM responses WHERE created_at < ?1", params![cutoff])?;
405        drop(conn);
406        self.clear_caches()?;
407        Ok(n as u64)
408    }
409}
410
411impl Vault for SqliteVault {
412    fn get_or_create(&self, scope: &ScopeId, kind: &EntityKind, value: &str) -> Result<u64, veil_engine::VaultError> {
413        Ok(self.get_or_create_inner(scope, kind, value)?)
414    }
415
416    fn lookup(&self, scope: &ScopeId, kind: &EntityKind, n: u64) -> Result<Option<String>, veil_engine::VaultError> {
417        Ok(self.lookup_inner(scope, kind, n)?)
418    }
419
420    fn reserve(&self, scope: &ScopeId, kind: &EntityKind, n: u64) -> Result<(), veil_engine::VaultError> {
421        self.conn()?
422            .execute(
423                "INSERT INTO counters (scope, kind, last) VALUES (?1, ?2, ?3)
424                 ON CONFLICT (scope, kind) DO UPDATE SET last = max(last, excluded.last)",
425                params![scope.as_str(), kind.as_str(), n as i64],
426            )
427            .map_err(Error::from)?;
428        Ok(())
429    }
430}
431
432impl ManagedVault for SqliteVault {
433    fn conversation_scope(&self, hint: &str) -> ScopeId {
434        ScopeId::keyed(&self.keys.scope, hint)
435    }
436
437    // Bindings are in the `responses` table, keyed by an HMAC of the ID
438    // under the label `response`: the names of earlier builds, kept so
439    // existing vaults keep working.
440    fn bind_scope(&self, id: &str, scope: &ScopeId) -> Result<(), Error> {
441        let id_mac = self.keys.mac(&[b"response", id.as_bytes()]);
442        self.conn()?.execute(
443            "INSERT OR REPLACE INTO responses (id_mac, scope, created_at) VALUES (?1, ?2, ?3)",
444            params![id_mac, scope.as_str(), now()],
445        )?;
446        Ok(())
447    }
448
449    fn bound_scope(&self, id: &str) -> Result<Option<ScopeId>, Error> {
450        let id_mac = self.keys.mac(&[b"response", id.as_bytes()]);
451        let scope: Option<String> = self
452            .conn()?
453            .query_row("SELECT scope FROM responses WHERE id_mac = ?1", params![id_mac], |r| r.get(0))
454            .optional()?;
455        Ok(scope.map(ScopeId::new))
456    }
457
458    fn info(&self) -> Result<VaultInfo, Error> {
459        let (entries, oldest): (i64, Option<i64>) =
460            self.conn()?
461                .query_row("SELECT count(*), min(created_at) FROM entries", [], |r| Ok((r.get(0)?, r.get(1)?)))?;
462        Ok(VaultInfo { entries: entries as u64, oldest_created_at: oldest, persistent: true })
463    }
464
465    fn clear(&self) -> Result<(), Error> {
466        // Counters are kept, so cleared numbers are never handed out again.
467        self.conn()?.execute_batch("DELETE FROM entries; DELETE FROM responses; VACUUM;")?;
468        self.clear_caches()
469    }
470
471    fn purge_expired(&self) -> Result<u64, Error> {
472        let cutoff = now() - self.ttl.as_secs() as i64;
473        self.purge_before(cutoff)
474    }
475
476    fn adopt(&self, from: &ScopeId, into: &ScopeId) -> Result<bool, Error> {
477        self.adopt_inner(from, into)
478    }
479}
480
481fn now() -> i64 {
482    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
483}
484
485pub(crate) fn random_key() -> MasterKey {
486    let mut key = [0u8; 32];
487    getrandom::fill(&mut key).expect("system random source");
488    key
489}
490
491fn to_hex(b: &[u8]) -> String {
492    b.iter().map(|x| format!("{x:02x}")).collect()
493}
494
495fn parse_hex(s: &str) -> Option<MasterKey> {
496    if s.len() != 64 {
497        return None;
498    }
499    let mut key = [0u8; 32];
500    for (i, k) in key.iter_mut().enumerate() {
501        *k = u8::from_str_radix(s.get(i * 2..i * 2 + 2)?, 16).ok()?;
502    }
503    Some(key)
504}
505
506/// Creates a new file readable and writable only by the current user.
507fn private_file(path: &Path) -> std::io::Result<std::fs::File> {
508    let mut opts = OpenOptions::new();
509    opts.write(true).create_new(true);
510    #[cfg(unix)]
511    std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
512    opts.open(path)
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    const DAY: Duration = Duration::from_secs(86_400);
520
521    fn kind(s: &str) -> EntityKind {
522        EntityKind::new(s).unwrap()
523    }
524
525    fn scope() -> ScopeId {
526        ScopeId::profile("default")
527    }
528
529    fn open(dir: &tempfile::TempDir, key: &MasterKey) -> SqliteVault {
530        SqliteVault::open(&dir.path().join("vault.db"), key, 7 * DAY).unwrap()
531    }
532
533    #[test]
534    fn adopt_copies_a_scope_into_an_unused_one() {
535        let dir = tempfile::tempdir().unwrap();
536        let key = random_key();
537        let v = open(&dir, &key);
538        let (draft, chat) = (v.conversation_scope("draft"), v.conversation_scope("chat"));
539        let email = kind("EMAIL");
540        assert_eq!(v.get_or_create(&draft, &email, "ana@example.org").unwrap(), 1);
541        assert_eq!(v.get_or_create(&draft, &email, "bob@example.org").unwrap(), 2);
542        assert!(v.adopt(&draft, &chat).unwrap());
543        assert_eq!(v.lookup(&chat, &email, 2).unwrap().as_deref(), Some("bob@example.org"));
544        // The same values keep their numbers, and new ones come after.
545        assert_eq!(v.get_or_create(&chat, &email, "ana@example.org").unwrap(), 1);
546        assert_eq!(v.get_or_create(&chat, &email, "cy@example.org").unwrap(), 3);
547        // A scope that has handed out numbers is never written over.
548        let other = v.conversation_scope("other");
549        v.get_or_create(&other, &email, "cy@example.org").unwrap();
550        assert!(!v.adopt(&draft, &other).unwrap());
551        assert_eq!(v.lookup(&other, &email, 2).unwrap(), None);
552        assert!(!v.adopt(&draft, &chat).unwrap());
553        // What was adopted is on disk, sealed for its new scope.
554        drop(v);
555        let v = open(&dir, &key);
556        assert_eq!(v.lookup(&chat, &email, 1).unwrap().as_deref(), Some("ana@example.org"));
557    }
558
559    #[test]
560    fn numbers_are_stable_and_survive_reopen() {
561        let dir = tempfile::tempdir().unwrap();
562        let key = random_key();
563        let v = open(&dir, &key);
564        assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "ana@example.com").unwrap(), 1);
565        assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "bob@example.com").unwrap(), 2);
566        assert_eq!(v.get_or_create(&scope(), &kind("PHONE"), "+40 721 555 019").unwrap(), 1);
567        assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "ana@example.com").unwrap(), 1);
568        drop(v);
569        let v = open(&dir, &key);
570        assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "ana@example.com").unwrap(), 1);
571        assert_eq!(v.lookup(&scope(), &kind("EMAIL"), 2).unwrap().as_deref(), Some("bob@example.com"));
572        assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "new@example.com").unwrap(), 3);
573        assert_eq!(v.lookup(&ScopeId::profile("other"), &kind("EMAIL"), 1).unwrap(), None);
574    }
575
576    #[test]
577    fn wrong_key_is_refused() {
578        let dir = tempfile::tempdir().unwrap();
579        drop(open(&dir, &random_key()));
580        let err = SqliteVault::open(&dir.path().join("vault.db"), &random_key(), DAY).err().unwrap();
581        assert!(matches!(err, Error::WrongKey));
582    }
583
584    #[test]
585    fn values_are_not_stored_in_plain_text() {
586        let dir = tempfile::tempdir().unwrap();
587        let v = open(&dir, &random_key());
588        v.get_or_create(&scope(), &kind("EMAIL"), "canary-7f3a9c@example.com").unwrap();
589        drop(v);
590        for entry in std::fs::read_dir(dir.path()).unwrap() {
591            let bytes = std::fs::read(entry.unwrap().path()).unwrap();
592            assert!(!bytes.windows(13).any(|w| w == b"canary-7f3a9c"), "plain value found on disk");
593        }
594    }
595
596    #[test]
597    fn ciphertexts_are_bound_to_their_slot() {
598        let dir = tempfile::tempdir().unwrap();
599        let v = open(&dir, &random_key());
600        v.get_or_create(&scope(), &kind("EMAIL"), "a@example.com").unwrap();
601        v.get_or_create(&scope(), &kind("EMAIL"), "b@example.com").unwrap();
602        // Swap the two ciphertexts on disk; decryption must fail rather than mix them up.
603        {
604            let c = v.conn.lock().unwrap();
605            c.execute_batch(
606                "UPDATE entries SET n = -1 WHERE n = 1; UPDATE entries SET n = 1 WHERE n = 2; UPDATE entries SET n = 2 WHERE n = -1;",
607            )
608            .unwrap();
609        }
610        v.clear_caches().unwrap();
611        assert!(v.lookup(&scope(), &kind("EMAIL"), 1).is_err());
612    }
613
614    #[test]
615    fn reserve_skips_numbers() {
616        let dir = tempfile::tempdir().unwrap();
617        let v = open(&dir, &random_key());
618        v.reserve(&scope(), &kind("EMAIL"), 5).unwrap();
619        v.reserve(&scope(), &kind("EMAIL"), 2).unwrap();
620        assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "a@example.com").unwrap(), 6);
621    }
622
623    #[test]
624    fn purge_and_clear_keep_counters() {
625        let dir = tempfile::tempdir().unwrap();
626        let v = open(&dir, &random_key());
627        v.get_or_create(&scope(), &kind("EMAIL"), "a@example.com").unwrap();
628        v.bind_scope("resp_1", &scope()).unwrap();
629        assert_eq!(v.purge_expired().unwrap(), 0);
630        assert_eq!(v.purge_before(now() + 1).unwrap(), 1);
631        assert_eq!(v.info().unwrap().entries, 0);
632        assert_eq!(v.lookup(&scope(), &kind("EMAIL"), 1).unwrap(), None);
633        assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "a@example.com").unwrap(), 2);
634        v.clear().unwrap();
635        assert_eq!(v.info().unwrap().entries, 0);
636        assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "a@example.com").unwrap(), 3);
637    }
638
639    #[test]
640    fn responses_and_conversation_scopes() {
641        let dir = tempfile::tempdir().unwrap();
642        let key = random_key();
643        let v = open(&dir, &key);
644        let conv = v.conversation_scope("system prompt + first message");
645        assert!(conv.as_str().starts_with("conv:"));
646        assert!(!conv.as_str().contains("system"));
647        assert_eq!(conv, open(&tempfile::tempdir().unwrap(), &key).conversation_scope("system prompt + first message"));
648        v.bind_scope("resp_abc", &conv).unwrap();
649        assert_eq!(v.bound_scope("resp_abc").unwrap(), Some(conv));
650        assert_eq!(v.bound_scope("resp_other").unwrap(), None);
651    }
652
653    #[test]
654    fn key_file_is_created_once_and_private() {
655        let dir = tempfile::tempdir().unwrap();
656        let path = dir.path().join("keys/vault.key");
657        let a = KeySource::File(path.clone()).load_or_create().unwrap();
658        let b = KeySource::File(path.clone()).load_or_create().unwrap();
659        assert_eq!(a, b);
660        #[cfg(unix)]
661        {
662            use std::os::unix::fs::PermissionsExt;
663            assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
664        }
665        std::fs::write(&path, "not a key").unwrap();
666        assert!(matches!(KeySource::File(path).load_or_create(), Err(Error::KeyFormat(_))));
667    }
668
669    #[test]
670    fn works_with_the_engine() {
671        use std::sync::Arc;
672        use veil_engine::Engine;
673        let dir = tempfile::tempdir().unwrap();
674        let key = random_key();
675        let text = "Mail ana@example.com about card 4111 1111 1111 1111";
676        let redacted = {
677            let e = Engine::builder().vault(Arc::new(open(&dir, &key))).build().unwrap();
678            e.redact_text(&scope(), text).unwrap().text
679        };
680        assert_eq!(redacted, "Mail [EMAIL_1] about card [CARD_1]");
681        let e = Engine::builder().vault(Arc::new(open(&dir, &key))).build().unwrap();
682        assert_eq!(e.restore_text(&scope(), &redacted).text, text);
683    }
684}