svault-ai 1.0.0

Secret access layer for cooperative AI agents — structured, policy-gated, audited credential access
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
//! Master key + per-store keyslots — one passphrase that opens every vault.
//!
//! Before 0.9.4 each vault had its own passphrase (and the keyring a separate
//! one). That is up to N+1 secrets to remember. This module replaces that with a
//! single **master passphrase**, using the keyslot model that `recovery.rs`
//! already proved out:
//!
//! - A random 32-byte **master key (MK)** is generated once and wrapped under a
//!   passphrase-derived KEK (Argon2id) in `.svault/master.enc`. That file is a
//!   keyslot — exactly the `[salt][nonce][wrapped-MK]` shape as `recovery.enc`.
//! - Each vault has its own random 32-byte **data key (DEK)** that encrypts its
//!   `vault.enc`. The DEK is wrapped under MK (not under any passphrase) in
//!   `<vault_dir>/keyslot.enc`.
//!
//! Unlock the master once → unwrap MK → unwrap each vault's DEK → cache it in
//! that vault's existing `0600` session. Because every DEK is wrapped under the
//! *same* MK, one master passphrase opens everything; and because MK is the only
//! thing the passphrase wraps, future unlock methods (a YubiKey touch, a recovery
//! code) are just additional keyslots over MK — any one of them opens it all.
//!
//! Honest boundary: this is the same-UID trust model as the rest of Svault. The
//! keyslots close the read-the-files path; they are not a sandbox against a
//! hostile same-UID process reading the unlocked daemon's memory or a `0600`
//! session.
#![allow(dead_code)]

use anyhow::{anyhow, Result};
use rand::RngCore;
use std::path::{Path, PathBuf};

use crate::core::crypto::{self, VaultKey, SALT_SIZE};
use crate::core::vault::svault_dir;

const MASTER_FILE: &str = "master.enc";
const MASTER_SESSION: &str = ".master.session";
/// Recovery keyslot wrapping the master key under a 160-bit code — the way back
/// in if the master passphrase is forgotten (opens every store).
const MASTER_RECOVERY: &str = "master.recovery.enc";
/// Per-vault keyslot wrapping that vault's DEK under the master key.
pub const VAULT_KEYSLOT: &str = "keyslot.enc";
/// Keyslot wrapping the keyring's DEK under the master key. The keyring lives at
/// `.svault/keyring.enc` (not in a vault subdir), so its slot needs its own name.
pub const KEYRING_KEYSLOT: &str = "keyring.keyslot.enc";
/// YubiKey keyslot: the master key wrapped under a FIDO2 hmac-secret-derived KEK
/// — an alternative way in, the same shape as the recovery slot.
const MASTER_YUBIKEY: &str = "master.yubikey.enc";
/// Non-secret companion to the YubiKey slot: the FIDO2 credential id and the
/// hmac-secret salt needed to re-derive that KEK. JSON, owner-only.
const MASTER_YUBIKEY_META: &str = "master.yubikey.meta";

fn master_path() -> PathBuf {
    svault_dir().join(MASTER_FILE)
}

fn session_path() -> PathBuf {
    svault_dir().join(MASTER_SESSION)
}

fn keyslot_path(vault_dir: &Path) -> PathBuf {
    vault_dir.join(VAULT_KEYSLOT)
}

fn keyring_keyslot_path() -> PathBuf {
    svault_dir().join(KEYRING_KEYSLOT)
}

fn master_recovery_path() -> PathBuf {
    svault_dir().join(MASTER_RECOVERY)
}

fn yubikey_path() -> PathBuf {
    svault_dir().join(MASTER_YUBIKEY)
}

fn yubikey_meta_path() -> PathBuf {
    svault_dir().join(MASTER_YUBIKEY_META)
}

/// True if a master recovery code has been written for this machine.
pub fn master_recovery_exists() -> bool {
    master_recovery_path().exists()
}

/// True if a YubiKey is enrolled as a master keyslot on this machine.
pub fn yubikey_enrolled() -> bool {
    yubikey_path().exists() && yubikey_meta_path().exists()
}

/// True once a master passphrase has been set on this machine.
pub fn exists() -> bool {
    master_path().exists()
}

/// True if the given vault is wrapped under the master key (has a keyslot).
pub fn vault_has_keyslot(vault_dir: &Path) -> bool {
    keyslot_path(vault_dir).exists()
}

/// True if the keyring is wrapped under the master key (has a keyslot).
pub fn keyring_has_keyslot() -> bool {
    keyring_keyslot_path().exists()
}

/// An open master: the master key (MK) held in memory, used to wrap/unwrap the
/// per-vault data keys. Zeroized on drop via the inner [`VaultKey`].
pub struct Master {
    mk: VaultKey,
}

impl Master {
    /// Create the master: generate a random MK and wrap it under `passphrase`.
    /// Errors if a master already exists (callers check [`exists`] first).
    pub fn init(passphrase: &str) -> Result<Self> {
        let path = master_path();
        if path.exists() {
            return Err(anyhow!("a master passphrase is already set"));
        }
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() {
                crate::core::secfile::create_dir_owner_only(parent)?;
            }
        }
        let mut mk_bytes = [0u8; 32];
        rand::thread_rng().fill_bytes(&mut mk_bytes);
        let mk = VaultKey::from_bytes(mk_bytes);
        write_master_slot(&path, &mk, passphrase)?;
        Ok(Self { mk })
    }

    /// Open the master with its passphrase. Wrong passphrase → the GCM tag fails.
    pub fn open(passphrase: &str) -> Result<Self> {
        let blob = std::fs::read(master_path())
            .map_err(|_| anyhow!("no master passphrase set — run 'svault master init'"))?;
        if blob.len() < SALT_SIZE {
            return Err(anyhow!("master.enc is too short — may be corrupted"));
        }
        let salt = &blob[..SALT_SIZE];
        let kek = VaultKey::derive(passphrase, salt)?;
        let mk_bytes =
            crypto::decrypt(&kek, &blob).map_err(|_| anyhow!("wrong master passphrase"))?;
        let mk_bytes: [u8; 32] = mk_bytes
            .try_into()
            .map_err(|_| anyhow!("master.enc holds an unexpected key length"))?;
        Ok(Self {
            mk: VaultKey::from_bytes(mk_bytes),
        })
    }

    /// Open from the cached master session key (the daemon / CLI fallback path).
    pub fn open_with_key(mk: [u8; 32]) -> Self {
        Self {
            mk: VaultKey::from_bytes(mk),
        }
    }

    /// Re-wrap MK under a new passphrase (fresh salt). The MK — and therefore
    /// every vault's DEK and ciphertext — is untouched; only the slot changes.
    pub fn rekey(&self, new_passphrase: &str) -> Result<()> {
        write_master_slot(&master_path(), &self.mk, new_passphrase)
    }

    /// Generate a recovery code, wrap MK under it, and write the recovery slot.
    /// Shown once at master creation; any later master-passphrase reset uses it
    /// (see [`recover`]). Because it wraps MK directly, this one code opens every
    /// store (all vaults + the keyring).
    pub fn write_recovery(&self) -> Result<String> {
        let code = crate::core::recovery::generate_code();
        crate::core::recovery::write_at(&master_recovery_path(), &self.mk, &code)?;
        Ok(code)
    }

    /// The raw master key bytes — for caching the session. Never written to a
    /// non-owner-only file.
    pub fn key_bytes(&self) -> &[u8; 32] {
        self.mk.bytes()
    }

    /// Enroll a YubiKey as an alternative master keyslot. Creates a FIDO2
    /// credential, derives a stable hmac-secret from it, and wraps MK under that
    /// secret in `master.yubikey.enc` (with its `.meta` companion). Needs an open
    /// master (MK in hand) plus two touches. `pin` is the YubiKey PIN if one is
    /// set. Because it wraps MK directly, the key then opens every store — and the
    /// passphrase keeps working (this is an *or*, never a 2FA requirement).
    pub fn enroll_yubikey(&self, pin: Option<&str>) -> Result<()> {
        let credential_id = crate::core::yubikey::enroll(pin)?;
        let mut hmac_salt = [0u8; 32];
        rand::thread_rng().fill_bytes(&mut hmac_salt);
        let secret = crate::core::yubikey::derive_secret(&credential_id, &hmac_salt, pin)?;

        // The hmac-secret is already 32 high-entropy bytes, so it is the KEK
        // directly (no Argon2) — same reasoning as wrap_dek_at over MK.
        let kek = VaultKey::from_bytes(secret);
        let mut aes_salt = [0u8; SALT_SIZE];
        rand::thread_rng().fill_bytes(&mut aes_salt);
        let blob = crypto::encrypt(&kek, &aes_salt, self.mk.bytes())?;
        crate::core::secfile::write_owner_only(&yubikey_path(), &blob)?;

        let meta = YubiMeta {
            credential_id: hex::encode(&credential_id),
            hmac_salt: hex::encode(hmac_salt),
        };
        let json = serde_json::to_vec(&meta)?;
        crate::core::secfile::write_owner_only(&yubikey_meta_path(), &json)?;
        Ok(())
    }

    /// Wrap a vault's data key under MK and write `<vault_dir>/keyslot.enc`.
    pub fn wrap_dek(&self, vault_dir: &Path, dek: &VaultKey) -> Result<()> {
        self.wrap_dek_at(&keyslot_path(vault_dir), dek)
    }

    /// Unwrap a vault's data key from its keyslot using MK.
    pub fn unwrap_dek(&self, vault_dir: &Path) -> Result<VaultKey> {
        self.unwrap_dek_at(
            &keyslot_path(vault_dir),
            "vault is not wrapped under the master key (no keyslot.enc)",
        )
    }

    /// Wrap the keyring's data key under MK and write `.svault/keyring.keyslot.enc`.
    pub fn wrap_keyring_dek(&self, dek: &VaultKey) -> Result<()> {
        self.wrap_dek_at(&keyring_keyslot_path(), dek)
    }

    /// Unwrap the keyring's data key from its keyslot using MK.
    pub fn unwrap_keyring_dek(&self) -> Result<VaultKey> {
        self.unwrap_dek_at(
            &keyring_keyslot_path(),
            "keyring is not wrapped under the master key (no keyring.keyslot.enc)",
        )
    }

    /// Wrap a DEK under MK and write the keyslot at `path`.
    fn wrap_dek_at(&self, path: &Path, dek: &VaultKey) -> Result<()> {
        let mut salt = [0u8; SALT_SIZE];
        rand::thread_rng().fill_bytes(&mut salt);
        // MK is 32 random bytes already — high entropy, so AES-GCM under MK
        // directly is enough (no second Argon2 pass). The salt is stored only to
        // keep the on-disk shape identical to the other keyslots.
        let blob = crypto::encrypt(&self.mk, &salt, dek.bytes())?;
        crate::core::secfile::write_owner_only(path, &blob)?;
        Ok(())
    }

    /// Unwrap a DEK from the keyslot at `path` using MK. `missing` is the error
    /// shown when the slot file does not exist.
    fn unwrap_dek_at(&self, path: &Path, missing: &str) -> Result<VaultKey> {
        let blob = std::fs::read(path).map_err(|_| anyhow!("{missing}"))?;
        let dek_bytes = crypto::decrypt(&self.mk, &blob)
            .map_err(|_| anyhow!("could not unwrap the data key with this master"))?;
        let dek_bytes: [u8; 32] = dek_bytes
            .try_into()
            .map_err(|_| anyhow!("keyslot holds an unexpected key length"))?;
        Ok(VaultKey::from_bytes(dek_bytes))
    }
}

/// Reset the master passphrase using the recovery code: unwrap MK from the
/// recovery slot, then re-wrap it under `new_passphrase` (the recovery slot
/// itself is left unchanged, so the code keeps working). MK never changes, so
/// every vault and the keyring stay accessible.
pub fn recover(code: &str, new_passphrase: &str) -> Result<Master> {
    let path = master_recovery_path();
    if !path.exists() {
        return Err(anyhow!(
            "no master recovery code on this machine (master.recovery.enc missing)"
        ));
    }
    let mk = crate::core::recovery::unlock_at(&path, code)?;
    write_master_slot(&master_path(), &mk, new_passphrase)?;
    Ok(Master { mk })
}

/// Non-secret metadata for the YubiKey keyslot: enough to re-derive the KEK.
#[derive(serde::Serialize, serde::Deserialize)]
struct YubiMeta {
    credential_id: String,
    hmac_salt: String,
}

/// Open the master via an enrolled YubiKey: re-derive the hmac-secret from the
/// stored credential id + salt (touch, + PIN if set), then unwrap MK from
/// `master.yubikey.enc`. MK never changes, so every store stays accessible —
/// this is an alternative to typing the master passphrase, not a second factor.
pub fn open_with_yubikey(pin: Option<&str>) -> Result<Master> {
    if !yubikey_enrolled() {
        return Err(anyhow!(
            "no YubiKey is enrolled on this machine (run 'svault master yubikey enroll')"
        ));
    }
    let meta: YubiMeta = serde_json::from_slice(&std::fs::read(yubikey_meta_path())?)
        .map_err(|_| anyhow!("master.yubikey.meta is corrupted"))?;
    let credential_id = hex::decode(&meta.credential_id)
        .map_err(|_| anyhow!("bad credential id in YubiKey meta"))?;
    let salt: [u8; 32] = hex::decode(&meta.hmac_salt)
        .ok()
        .and_then(|b| b.try_into().ok())
        .ok_or_else(|| anyhow!("bad hmac salt in YubiKey meta"))?;

    let secret = crate::core::yubikey::derive_secret(&credential_id, &salt, pin)?;
    let kek = VaultKey::from_bytes(secret);
    let blob = std::fs::read(yubikey_path())?;
    let mk_bytes = crypto::decrypt(&kek, &blob)
        .map_err(|_| anyhow!("this YubiKey did not unwrap the master key"))?;
    let mk_bytes: [u8; 32] = mk_bytes
        .try_into()
        .map_err(|_| anyhow!("master.yubikey.enc holds an unexpected key length"))?;
    Ok(Master {
        mk: VaultKey::from_bytes(mk_bytes),
    })
}

/// Remove the enrolled YubiKey keyslot (the passphrase and recovery code still
/// open the master). Both the slot and its meta are securely removed.
pub fn remove_yubikey() -> Result<()> {
    crate::core::session::secure_remove(&yubikey_path())?;
    crate::core::session::secure_remove(&yubikey_meta_path())?;
    Ok(())
}

/// Generate a random DEK for a new store. The caller wraps it under the master
/// and uses it to encrypt the store.
pub fn new_dek() -> VaultKey {
    let mut bytes = [0u8; 32];
    rand::thread_rng().fill_bytes(&mut bytes);
    VaultKey::from_bytes(bytes)
}

/// Wrap `mk` under a key derived from `passphrase` and write the master slot.
fn write_master_slot(path: &Path, mk: &VaultKey, passphrase: &str) -> Result<()> {
    let mut salt = [0u8; SALT_SIZE];
    rand::thread_rng().fill_bytes(&mut salt);
    let kek = VaultKey::derive(passphrase, &salt)?;
    let blob = crypto::encrypt(&kek, &salt, mk.bytes())?;
    crate::core::secfile::write_owner_only(path, &blob)?;
    Ok(())
}

// ── Session caching (mirrors session.rs / keyring.rs) ────────────────────────

/// Cache MK (`0600`, timestamped) so `create` / `enroll` don't re-prompt within
/// a session. Never stores the passphrase; expires after
/// [`crate::core::session::MAX_SESSION_SECS`] so the master must be re-entered.
pub fn unlock_session(mk: &[u8; 32]) -> Result<()> {
    crate::core::session::write_session_key(&session_path(), mk)
}

/// Clear the cached master session.
pub fn lock_session() -> Result<()> {
    crate::core::session::secure_remove(&session_path())?;
    Ok(())
}

/// The cached MK, if a valid (non-expired) master session exists.
pub fn session_key() -> Option<[u8; 32]> {
    crate::core::session::read_session_key(&session_path())
}

/// True if the master is unlocked (a usable session key is cached).
pub fn is_unlocked() -> bool {
    session_key().is_some()
}

/// Open the master from its cached session, if unlocked.
pub fn open_from_session() -> Option<Master> {
    Some(Master::open_with_key(session_key()?))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::testlock::CWD_LOCK;
    use crate::core::vault::SVAULT_DIR;
    use std::sync::MutexGuard;

    fn in_temp_cwd() -> (MutexGuard<'static, ()>, tempfile::TempDir, PathBuf) {
        // master.enc lives under .svault/ relative to the CWD, so disk-touching
        // tests must not run concurrently with any other chdir test — they all
        // share the one process-wide CWD lock.
        let guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let tmp = tempfile::TempDir::new().unwrap();
        let prev = std::env::current_dir().unwrap();
        std::env::set_current_dir(tmp.path()).unwrap();
        (guard, tmp, prev)
    }

    #[test]
    fn init_then_open_recovers_the_same_master_key() {
        let (_g, _tmp, prev) = in_temp_cwd();

        let m = Master::init("Master!Pass#1").unwrap();
        let mk = *m.key_bytes();
        drop(m);

        // Wrong passphrase is rejected; the right one returns the same MK.
        assert!(Master::open("nope").is_err());
        let reopened = Master::open("Master!Pass#1").unwrap();
        assert_eq!(reopened.key_bytes(), &mk);

        std::env::set_current_dir(prev).unwrap();
    }

    #[test]
    fn dek_wraps_under_master_and_unwraps_back() {
        let (_g, _tmp, prev) = in_temp_cwd();
        let m = Master::init("Master!Pass#2").unwrap();

        let vault_dir = PathBuf::from(SVAULT_DIR).join("v");
        crate::core::secfile::create_dir_owner_only(&vault_dir).unwrap();
        let dek = new_dek();
        let dek_bytes = *dek.bytes();
        m.wrap_dek(&vault_dir, &dek).unwrap();
        assert!(vault_has_keyslot(&vault_dir));

        let unwrapped = m.unwrap_dek(&vault_dir).unwrap();
        assert_eq!(unwrapped.bytes(), &dek_bytes);

        std::env::set_current_dir(prev).unwrap();
    }

    #[test]
    fn keyring_dek_wraps_under_master_and_unwraps_back() {
        let (_g, _tmp, prev) = in_temp_cwd();
        let m = Master::init("Master!Pass#KR").unwrap();
        crate::core::secfile::create_dir_owner_only(&PathBuf::from(SVAULT_DIR)).unwrap();

        let dek = new_dek();
        let dek_bytes = *dek.bytes();
        m.wrap_keyring_dek(&dek).unwrap();
        assert!(keyring_has_keyslot());

        let unwrapped = m.unwrap_keyring_dek().unwrap();
        assert_eq!(unwrapped.bytes(), &dek_bytes);

        std::env::set_current_dir(prev).unwrap();
    }

    #[test]
    fn rekey_keeps_master_key_and_keeps_unwrapping_vaults() {
        let (_g, _tmp, prev) = in_temp_cwd();
        let m = Master::init("Old!Master#1").unwrap();
        let vault_dir = PathBuf::from(SVAULT_DIR).join("v");
        crate::core::secfile::create_dir_owner_only(&vault_dir).unwrap();
        let dek = new_dek();
        let dek_bytes = *dek.bytes();
        m.wrap_dek(&vault_dir, &dek).unwrap();

        m.rekey("New!Master#2").unwrap();

        // Old passphrase no longer opens the master; the new one does and still
        // unwraps the same vault DEK (the DEK was never re-wrapped).
        assert!(Master::open("Old!Master#1").is_err());
        let reopened = Master::open("New!Master#2").unwrap();
        assert_eq!(reopened.unwrap_dek(&vault_dir).unwrap().bytes(), &dek_bytes);

        std::env::set_current_dir(prev).unwrap();
    }

    #[test]
    fn wrong_master_cannot_unwrap_a_dek() {
        let (_g, _tmp, prev) = in_temp_cwd();
        let m = Master::init("Right!Master#1").unwrap();
        let vault_dir = PathBuf::from(SVAULT_DIR).join("v");
        crate::core::secfile::create_dir_owner_only(&vault_dir).unwrap();
        m.wrap_dek(&vault_dir, &new_dek()).unwrap();

        // A different MK (from a fresh init in another dir) must not unwrap it.
        let other = Master::open_with_key([0x11u8; 32]);
        assert!(other.unwrap_dek(&vault_dir).is_err());

        std::env::set_current_dir(prev).unwrap();
    }

    #[test]
    fn recovery_code_resets_the_master_and_keeps_unwrapping_stores() {
        let (_g, _tmp, prev) = in_temp_cwd();
        let m = Master::init("Old!Master#R").unwrap();
        let vault_dir = PathBuf::from(SVAULT_DIR).join("v");
        crate::core::secfile::create_dir_owner_only(&vault_dir).unwrap();
        let dek = new_dek();
        let dek_bytes = *dek.bytes();
        m.wrap_dek(&vault_dir, &dek).unwrap();
        let code = m.write_recovery().unwrap();
        assert!(master_recovery_exists());
        drop(m);

        // Forgot the master passphrase: the recovery code resets it to a new one,
        // and the same MK still unwraps the vault DEK (nothing was re-encrypted).
        let recovered = recover(&code, "New!Master#R").unwrap();
        assert_eq!(
            recovered.unwrap_dek(&vault_dir).unwrap().bytes(),
            &dek_bytes
        );
        // The new passphrase now opens the master; a wrong code is rejected.
        assert!(Master::open("New!Master#R").is_ok());
        assert!(recover("0000-0000-0000-0000-0000-0000-0000-0000-0000-0000", "X").is_err());

        std::env::set_current_dir(prev).unwrap();
    }

    #[test]
    fn session_caches_mk_then_lock_clears() {
        let (_g, _tmp, prev) = in_temp_cwd();
        crate::core::secfile::create_dir_owner_only(&PathBuf::from(SVAULT_DIR)).unwrap();

        assert!(!is_unlocked());
        unlock_session(&[5u8; 32]).unwrap();
        assert!(is_unlocked());
        assert_eq!(session_key(), Some([5u8; 32]));
        lock_session().unwrap();
        assert!(!is_unlocked());

        std::env::set_current_dir(prev).unwrap();
    }
}