1pub 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; #[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
67pub struct Vault {
70 path: PathBuf,
71 passphrase: String,
72 contents: VaultContents,
73}
74
75impl Vault {
76 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 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 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 pub fn index(&self) -> Vec<ItemRef> {
117 self.contents.documents.iter().map(ItemRef::from).collect()
118 }
119
120 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 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 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 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 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 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
201pub 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
225pub 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 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 mod tempfile_path {
326 use std::path::{Path, PathBuf};
327 pub struct TempLike(PathBuf);
328 impl TempLike {
329 pub fn new() -> Self {
330 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}