Skip to main content

web4_core/vault/
mod.rs

1// Copyright (c) 2026 MetaLINXX Inc.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4//! # Recursive in-memory vault
5//!
6//! The shared storage substrate for hub / hestia / hardbound. Doctrine (see
7//! `dev-hub/design/recursive-vault.md`):
8//!
9//! 1. **Total enclosure** — config, identity, metadata, and state live inside an
10//!    encrypted vault, never in plaintext files. Each item is a [`Document`].
11//! 2. **Recursive locking** — the outer unlock yields the basics + an index;
12//!    individual items can be [`Protection::Sealed`] under an independent
13//!    credential, and a sealed item's plaintext can itself be a whole vault (a
14//!    sub-vault).
15//! 3. **Memory-only unlock** — decryption produces a zeroizing in-memory buffer
16//!    ([`open_document`](Vault::open_document)); nothing decrypted touches disk.
17//!    Persistence always re-encrypts.
18//!
19//! The on-disk file is `magic "W4VT" | version | salt(16) | nonce(12) |
20//! ChaCha20-Poly1305(serialized contents)`. The whole-file key is Argon2id over
21//! the master passphrase.
22//!
23//! This crate provides the generic container; applications store whatever they
24//! need as documents (an app's typed config/credential structs serialize to
25//! document bytes). Apps that want richer per-credential metadata layer it on
26//! top.
27
28pub mod crypto;
29pub mod document;
30
31pub use document::{Document, ItemRef, Protection};
32
33use std::fs::{self, File};
34use std::io::Write;
35use std::path::{Path, PathBuf};
36
37use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39use zeroize::Zeroizing;
40
41use crate::error::{Result, Web4Error};
42
43const MAGIC: &[u8; 4] = b"W4VT";
44const VERSION: u8 = 1;
45const HEADER_LEN: usize = 4 + 1 + 16 + 12; // 33
46
47/// The cleartext contents of a vault — serialized to JSON, then encrypted at
48/// rest. Generic: everything an application encloses is a [`Document`].
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct VaultContents {
51    pub version: u32,
52    pub created_at: DateTime<Utc>,
53    #[serde(default)]
54    pub documents: Vec<Document>,
55}
56
57impl Default for VaultContents {
58    fn default() -> Self {
59        Self {
60            version: 1,
61            created_at: Utc::now(),
62            documents: Vec::new(),
63        }
64    }
65}
66
67/// An open vault. Holds the decrypted contents in memory and the master
68/// passphrase needed to re-encrypt on save. Drop does not persist.
69pub struct Vault {
70    path: PathBuf,
71    passphrase: String,
72    contents: VaultContents,
73}
74
75impl Vault {
76    /// Open an existing vault file with the master passphrase. Decrypts the
77    /// contents INTO MEMORY.
78    pub fn open(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Result<Self> {
79        let path = path.into();
80        let passphrase = passphrase.into();
81        let contents = load(&path, &passphrase)?;
82        Ok(Self { path, passphrase, contents })
83    }
84
85    /// Create a new empty vault. Errors if a file already exists at `path`.
86    pub fn init(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Result<Self> {
87        let path = path.into();
88        if path.exists() {
89            return Err(Web4Error::Vault(format!("vault already exists: {}", path.display())));
90        }
91        Self::init_force(path, passphrase)
92    }
93
94    /// Create a new empty vault, overwriting any existing file.
95    pub fn init_force(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Result<Self> {
96        let path = path.into();
97        let passphrase = passphrase.into();
98        let contents = VaultContents::default();
99        save(&path, &passphrase, &contents)?;
100        Ok(Self { path, passphrase, contents })
101    }
102
103    pub fn path(&self) -> &Path {
104        &self.path
105    }
106
107    fn doc_pos(&self, namespace: &str, name: &str) -> Option<usize> {
108        self.contents
109            .documents
110            .iter()
111            .position(|d| d.namespace == namespace && d.name == name)
112    }
113
114    /// The content index: namespace + name + protection for every item, without
115    /// exposing sealed plaintext.
116    pub fn index(&self) -> Vec<ItemRef> {
117        self.contents.documents.iter().map(ItemRef::from).collect()
118    }
119
120    /// Store a master-tier document (config / metadata / state). Upserts by
121    /// (namespace, name) and persists.
122    pub fn put_document(&mut self, namespace: &str, name: &str, bytes: Vec<u8>) -> Result<()> {
123        self.upsert(Document::master(namespace, name, bytes))
124    }
125
126    /// Read a master-tier document's bytes. `None` if absent or sealed.
127    pub fn get_document(&self, namespace: &str, name: &str) -> Option<&[u8]> {
128        self.doc_pos(namespace, name)
129            .and_then(|i| self.contents.documents[i].master_bytes())
130    }
131
132    /// Store a document sealed under an INDEPENDENT `credential`. Upserts.
133    pub fn seal_document(
134        &mut self,
135        namespace: &str,
136        name: &str,
137        bytes: &[u8],
138        credential: &str,
139    ) -> Result<()> {
140        self.upsert(Document::sealed(namespace, name, bytes, credential)?)
141    }
142
143    /// Open a document INTO MEMORY. For a sealed document, `credential` is its
144    /// independent secret. Returns a zeroizing buffer; nothing touches disk.
145    pub fn open_document(
146        &self,
147        namespace: &str,
148        name: &str,
149        credential: &str,
150    ) -> Result<Zeroizing<Vec<u8>>> {
151        let i = self
152            .doc_pos(namespace, name)
153            .ok_or_else(|| Web4Error::NotFound(format!("{namespace}/{name}")))?;
154        self.contents.documents[i].open(credential)
155    }
156
157    pub fn remove_document(&mut self, namespace: &str, name: &str) -> Result<()> {
158        let i = self
159            .doc_pos(namespace, name)
160            .ok_or_else(|| Web4Error::NotFound(format!("{namespace}/{name}")))?;
161        self.contents.documents.remove(i);
162        self.save()
163    }
164
165    /// Store a nested vault, sealed under its own `credential` (recursion).
166    pub fn put_subvault(
167        &mut self,
168        namespace: &str,
169        name: &str,
170        sub: &VaultContents,
171        credential: &str,
172    ) -> Result<()> {
173        let bytes = serde_json::to_vec(sub)?;
174        self.seal_document(namespace, name, &bytes, credential)
175    }
176
177    /// Open a nested vault into memory with its `credential`.
178    pub fn open_subvault(
179        &self,
180        namespace: &str,
181        name: &str,
182        credential: &str,
183    ) -> Result<VaultContents> {
184        let bytes = self.open_document(namespace, name, credential)?;
185        Ok(serde_json::from_slice(&bytes)?)
186    }
187
188    fn upsert(&mut self, doc: Document) -> Result<()> {
189        match self.doc_pos(&doc.namespace, &doc.name) {
190            Some(i) => self.contents.documents[i] = doc,
191            None => self.contents.documents.push(doc),
192        }
193        self.save()
194    }
195
196    fn save(&self) -> Result<()> {
197        save(&self.path, &self.passphrase, &self.contents)
198    }
199}
200
201/// Read + decrypt a vault file into its contents.
202pub fn load(path: &Path, passphrase: &str) -> Result<VaultContents> {
203    if !path.exists() {
204        return Err(Web4Error::Vault(format!("vault not found: {}", path.display())));
205    }
206    let raw = fs::read(path).map_err(|e| Web4Error::Vault(format!("read {}: {e}", path.display())))?;
207    if raw.len() < HEADER_LEN {
208        return Err(Web4Error::Vault("file too short for header".into()));
209    }
210    if &raw[..4] != MAGIC {
211        return Err(Web4Error::Vault("wrong magic bytes".into()));
212    }
213    if raw[4] != VERSION {
214        return Err(Web4Error::Vault(format!("unsupported version: {}", raw[4])));
215    }
216    let mut salt = [0u8; 16];
217    salt.copy_from_slice(&raw[5..21]);
218    let mut nonce = [0u8; 12];
219    nonce.copy_from_slice(&raw[21..33]);
220    let key = crypto::derive_key(passphrase, &salt)?;
221    let plaintext = crypto::decrypt(&key, &nonce, &raw[HEADER_LEN..])?;
222    Ok(serde_json::from_slice(&plaintext)?)
223}
224
225/// Encrypt + atomically write vault contents. Fresh salt + nonce every write.
226pub fn save(path: &Path, passphrase: &str, contents: &VaultContents) -> Result<()> {
227    if let Some(parent) = path.parent() {
228        fs::create_dir_all(parent)
229            .map_err(|e| Web4Error::Vault(format!("mkdir {}: {e}", parent.display())))?;
230    }
231    let salt = crypto::generate_salt();
232    let nonce = crypto::generate_nonce();
233    let key = crypto::derive_key(passphrase, &salt)?;
234    let plaintext = serde_json::to_vec(contents)?;
235    let ciphertext = crypto::encrypt(&key, &nonce, &plaintext)?;
236
237    let mut buffer = Vec::with_capacity(HEADER_LEN + ciphertext.len());
238    buffer.extend_from_slice(MAGIC);
239    buffer.push(VERSION);
240    buffer.extend_from_slice(&salt);
241    buffer.extend_from_slice(&nonce);
242    buffer.extend_from_slice(&ciphertext);
243
244    let tmp = path.with_extension("w4vt.tmp");
245    {
246        let mut f = File::create(&tmp).map_err(|e| Web4Error::Vault(format!("create tmp: {e}")))?;
247        f.write_all(&buffer).map_err(|e| Web4Error::Vault(format!("write tmp: {e}")))?;
248        f.sync_all().map_err(|e| Web4Error::Vault(format!("sync tmp: {e}")))?;
249    }
250    fs::rename(&tmp, path).map_err(|e| Web4Error::Vault(format!("rename: {e}")))?;
251
252    #[cfg(unix)]
253    {
254        use std::os::unix::fs::PermissionsExt;
255        let mut perms = fs::metadata(path)
256            .map_err(|e| Web4Error::Vault(format!("stat: {e}")))?
257            .permissions();
258        perms.set_mode(0o600);
259        fs::set_permissions(path, perms).map_err(|e| Web4Error::Vault(format!("chmod: {e}")))?;
260    }
261    Ok(())
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn temp() -> (tempfile_path::TempLike, PathBuf) {
269        let dir = tempfile_path::TempLike::new();
270        let path = dir.path().join("v.w4vt");
271        (dir, path)
272    }
273
274    #[test]
275    fn master_doc_round_trips() {
276        let (_d, path) = temp();
277        let mut v = Vault::init(&path, "master").unwrap();
278        v.put_document("config", "daemon", b"bind=127.0.0.1".to_vec()).unwrap();
279        let v2 = Vault::open(&path, "master").unwrap();
280        assert_eq!(v2.get_document("config", "daemon").unwrap(), b"bind=127.0.0.1");
281        assert_eq!(v2.index()[0].protection, Protection::Master);
282    }
283
284    #[test]
285    fn sealed_needs_its_own_credential() {
286        let (_d, path) = temp();
287        let mut v = Vault::init(&path, "master").unwrap();
288        v.seal_document("identity", "sovereign_key", b"ed25519-secret", "second").unwrap();
289        let v2 = Vault::open(&path, "master").unwrap();
290        // Index shows it exists + sealed; master path can't read it.
291        assert!(matches!(v2.index()[0].protection, Protection::Sealed { .. }));
292        assert!(v2.get_document("identity", "sovereign_key").is_none());
293        assert!(v2.open_document("identity", "sovereign_key", "master").is_err());
294        let opened = v2.open_document("identity", "sovereign_key", "second").unwrap();
295        assert_eq!(&*opened, b"ed25519-secret");
296    }
297
298    #[test]
299    fn no_plaintext_on_disk() {
300        const M: &[u8] = b"MASTER_MARK_web4_zzz";
301        const S: &[u8] = b"SEALED_MARK_web4_qqq";
302        let (_d, path) = temp();
303        let mut v = Vault::init(&path, "master").unwrap();
304        v.put_document("c", "m", M.to_vec()).unwrap();
305        v.seal_document("c", "s", S, "cred").unwrap();
306        let raw = fs::read(&path).unwrap();
307        assert!(!raw.windows(M.len()).any(|w| w == M));
308        assert!(!raw.windows(S.len()).any(|w| w == S));
309    }
310
311    #[test]
312    fn subvault_recurses_and_locks_independently() {
313        let (_d, path) = temp();
314        let mut v = Vault::init(&path, "master").unwrap();
315        let mut sub = VaultContents::default();
316        sub.documents.push(Document::master("inner", "k", b"v".to_vec()));
317        v.put_subvault("nested", "child", &sub, "sub-cred").unwrap();
318        let v2 = Vault::open(&path, "master").unwrap();
319        assert!(v2.open_subvault("nested", "child", "master").is_err());
320        let opened = v2.open_subvault("nested", "child", "sub-cred").unwrap();
321        assert_eq!(opened.documents[0].master_bytes().unwrap(), b"v");
322    }
323
324    /// Minimal temp-dir helper (web4-core has no tempfile dev-dep).
325    mod tempfile_path {
326        use std::path::{Path, PathBuf};
327        pub struct TempLike(PathBuf);
328        impl TempLike {
329            pub fn new() -> Self {
330                // Unique per-process+counter dir under the system temp dir.
331                use std::sync::atomic::{AtomicU64, Ordering};
332                static N: AtomicU64 = AtomicU64::new(0);
333                let pid = std::process::id();
334                let n = N.fetch_add(1, Ordering::Relaxed);
335                let p = std::env::temp_dir().join(format!("w4vt-test-{pid}-{n}"));
336                std::fs::create_dir_all(&p).unwrap();
337                Self(p)
338            }
339            pub fn path(&self) -> &Path {
340                &self.0
341            }
342        }
343        impl Drop for TempLike {
344            fn drop(&mut self) {
345                let _ = std::fs::remove_dir_all(&self.0);
346            }
347        }
348    }
349}