Skip to main content

isb_core/secrets/
local.rs

1//! The `local` driver: age ciphertext on the daemon's disk.
2//!
3//! `<state>/orgs/<org>/secrets/<name>.age` holds the value encrypted to the
4//! [`Keyring`]'s recipients; `<name>.json` beside it holds the
5//! [`SecretMeta`]. Only the current value is kept. Files are 0600 in 0700
6//! directories, and every write is a temp file, fsync, rename.
7
8use std::collections::BTreeMap;
9use std::io::Write;
10use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, RwLock};
13
14use super::{Driver, Keyring, SecretMeta, not_found, validate_name};
15use crate::error::{Error, Result};
16use crate::org::OrgId;
17use crate::stack::now_secs;
18
19pub const DRIVER: &str = "local";
20
21pub struct LocalDriver {
22    state: PathBuf,
23    keyring: Arc<Keyring>,
24    /// Writers exclusive, readers shared, so a reader never pairs one
25    /// version's metadata with another's ciphertext.
26    lock: RwLock<()>,
27}
28
29impl LocalDriver {
30    pub fn new(state_dir: impl Into<PathBuf>, keyring: Arc<Keyring>) -> LocalDriver {
31        LocalDriver {
32            state: state_dir.into(),
33            keyring,
34            lock: RwLock::new(()),
35        }
36    }
37
38    pub fn keyring(&self) -> &Arc<Keyring> {
39        &self.keyring
40    }
41
42    /// `<state>/orgs/<org>/secrets`.
43    pub fn dir(&self, org: &OrgId) -> PathBuf {
44        org.dir(&self.state).join("secrets")
45    }
46
47    fn paths(&self, org: &OrgId, name: &str) -> Result<(PathBuf, PathBuf)> {
48        validate_name(name)?;
49        let d = self.dir(org);
50        Ok((
51            d.join(format!("{name}.age")),
52            d.join(format!("{name}.json")),
53        ))
54    }
55
56    fn ensure_dir(&self, org: &OrgId) -> Result<PathBuf> {
57        let d = self.dir(org);
58        std::fs::DirBuilder::new()
59            .recursive(true)
60            .mode(0o700)
61            .create(&d)?;
62        std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700))?;
63        Ok(d)
64    }
65
66    fn read_meta(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
67        let (_, meta) = self.paths(org, name)?;
68        match std::fs::read(&meta) {
69            Ok(b) => serde_json::from_slice(&b)
70                .map_err(|e| Error::invalid(format!("{}: {e}", meta.display()))),
71            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(not_found(org, name)),
72            Err(e) => Err(e.into()),
73        }
74    }
75
76    /// Ciphertext first, then metadata: a crash in between leaves the new
77    /// value under the old version, which the next write corrects.
78    fn write(&self, org: &OrgId, meta: &SecretMeta, value: &[u8]) -> Result<()> {
79        let dir = self.ensure_dir(org)?;
80        let (age, json) = self.paths(org, &meta.name)?;
81        write_atomic(&age, &self.keyring.encrypt(value)?)?;
82        write_atomic(&json, &serde_json::to_vec_pretty(meta)?)?;
83        fsync_dir(&dir);
84        Ok(())
85    }
86
87    fn decrypt(&self, org: &OrgId, name: &str) -> Result<Vec<u8>> {
88        let (age, _) = self.paths(org, name)?;
89        let ct = match std::fs::read(&age) {
90            Ok(b) => b,
91            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
92                return Err(Error::invalid(format!(
93                    "secret {name} in org {org}: metadata without a value ({} is missing); set it again",
94                    age.display()
95                )));
96            }
97            Err(e) => return Err(e.into()),
98        };
99        self.keyring.decrypt(&ct).map_err(|e| {
100            Error::invalid(format!(
101                "secret {name} in org {org}: cannot decrypt with this daemon's key ({e}); was it encrypted to another key?"
102            ))
103        })
104    }
105
106    fn orgs(&self) -> Result<Vec<OrgId>> {
107        let dir = self.state.join("orgs");
108        let rd = match std::fs::read_dir(&dir) {
109            Ok(rd) => rd,
110            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
111            Err(e) => return Err(e.into()),
112        };
113        let mut out = Vec::new();
114        for e in rd {
115            let e = e?;
116            if let Some(o) = e.file_name().to_str().and_then(|n| OrgId::new(n).ok()) {
117                if e.path().join("secrets").is_dir() {
118                    out.push(o);
119                }
120            }
121        }
122        out.sort();
123        Ok(out)
124    }
125
126    fn read_guard(&self) -> std::sync::RwLockReadGuard<'_, ()> {
127        self.lock.read().unwrap_or_else(|p| p.into_inner())
128    }
129
130    fn write_guard(&self) -> std::sync::RwLockWriteGuard<'_, ()> {
131        self.lock.write().unwrap_or_else(|p| p.into_inner())
132    }
133}
134
135impl Driver for LocalDriver {
136    fn name(&self) -> &str {
137        DRIVER
138    }
139
140    fn get(&self, org: &OrgId, name: &str) -> Result<(Vec<u8>, u64)> {
141        let _g = self.read_guard();
142        let meta = self.read_meta(org, name)?;
143        Ok((self.decrypt(org, name)?, meta.version))
144    }
145
146    fn version(&self, org: &OrgId, name: &str) -> Result<u64> {
147        let _g = self.read_guard();
148        Ok(self.read_meta(org, name)?.version)
149    }
150
151    fn inspect(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
152        let _g = self.read_guard();
153        self.read_meta(org, name)
154    }
155
156    fn list(&self, org: &OrgId) -> Result<Vec<SecretMeta>> {
157        let _g = self.read_guard();
158        let rd = match std::fs::read_dir(self.dir(org)) {
159            Ok(rd) => rd,
160            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
161            Err(e) => return Err(e.into()),
162        };
163        let mut out = Vec::new();
164        for e in rd {
165            let p = e?.path();
166            if p.extension().is_none_or(|x| x != "json") {
167                continue;
168            }
169            match std::fs::read(&p)
170                .map_err(Error::from)
171                .and_then(|b| serde_json::from_slice::<SecretMeta>(&b).map_err(Error::from))
172            {
173                Ok(m) => out.push(m),
174                Err(e) => eprintln!("isb secrets: skipping {}: {e}", p.display()),
175            }
176        }
177        out.sort_by(|a, b| a.name.cmp(&b.name));
178        Ok(out)
179    }
180
181    fn create(
182        &self,
183        org: &OrgId,
184        name: &str,
185        value: &[u8],
186        labels: &BTreeMap<String, String>,
187    ) -> Result<SecretMeta> {
188        let _g = self.write_guard();
189        match self.read_meta(org, name) {
190            Ok(_) => return Err(super::exists(org, name)),
191            Err(Error::NotFound(_)) => {}
192            Err(e) => return Err(e),
193        }
194        let now = now_secs();
195        let meta = SecretMeta {
196            org: org.clone(),
197            name: name.to_string(),
198            driver: DRIVER.into(),
199            version: 1,
200            created_at: now,
201            updated_at: now,
202            labels: labels.clone(),
203        };
204        self.write(org, &meta, value)?;
205        Ok(meta)
206    }
207
208    fn set(&self, org: &OrgId, name: &str, value: &[u8]) -> Result<u64> {
209        let _g = self.write_guard();
210        let now = now_secs();
211        let meta = match self.read_meta(org, name) {
212            Ok(mut m) => {
213                m.version += 1;
214                m.updated_at = now;
215                m
216            }
217            Err(Error::NotFound(_)) => SecretMeta {
218                org: org.clone(),
219                name: name.to_string(),
220                driver: DRIVER.into(),
221                version: 1,
222                created_at: now,
223                updated_at: now,
224                labels: BTreeMap::new(),
225            },
226            Err(e) => return Err(e),
227        };
228        self.write(org, &meta, value)?;
229        Ok(meta.version)
230    }
231
232    fn delete(&self, org: &OrgId, name: &str) -> Result<()> {
233        let _g = self.write_guard();
234        self.read_meta(org, name)?;
235        let (age, json) = self.paths(org, name)?;
236        // Metadata first: a crash in between leaves an orphan .age that
237        // nothing lists, not a listed secret without a value.
238        std::fs::remove_file(&json)?;
239        match std::fs::remove_file(&age) {
240            Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
241            _ => {}
242        }
243        fsync_dir(&self.dir(org));
244        Ok(())
245    }
246
247    fn reencrypt(&self, org: Option<&OrgId>) -> Result<usize> {
248        let _g = self.write_guard();
249        let orgs = match org {
250            Some(o) => vec![o.clone()],
251            None => self.orgs()?,
252        };
253        let mut n = 0;
254        for o in &orgs {
255            let rd = match std::fs::read_dir(self.dir(o)) {
256                Ok(rd) => rd,
257                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
258                Err(e) => return Err(e.into()),
259            };
260            let mut names = Vec::new();
261            for e in rd {
262                let p = e?.path();
263                if p.extension().is_some_and(|x| x == "json") {
264                    let b = std::fs::read(&p)?;
265                    if let Ok(m) = serde_json::from_slice::<SecretMeta>(&b) {
266                        names.push(m.name);
267                    }
268                }
269            }
270            names.sort();
271            for name in names {
272                let value = self.decrypt(o, &name)?;
273                let (age, _) = self.paths(o, &name)?;
274                write_atomic(&age, &self.keyring.encrypt(&value)?)?;
275                n += 1;
276            }
277            fsync_dir(&self.dir(o));
278        }
279        Ok(n)
280    }
281}
282
283/// Write `bytes` to `path` via `<path>.tmp`: 0600, fsync, rename.
284pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
285    let mut tmp = path.as_os_str().to_owned();
286    tmp.push(".tmp");
287    let tmp = PathBuf::from(tmp);
288    let r = (|| -> Result<()> {
289        let mut f = std::fs::OpenOptions::new()
290            .write(true)
291            .create(true)
292            .truncate(true)
293            .mode(0o600)
294            .open(&tmp)?;
295        // A pre-existing temp file keeps its old mode through O_TRUNC.
296        f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
297        f.write_all(bytes)?;
298        f.sync_all()?;
299        std::fs::rename(&tmp, path)?;
300        Ok(())
301    })();
302    if r.is_err() {
303        let _ = std::fs::remove_file(&tmp);
304    }
305    r
306}
307
308/// Make a rename durable. Best effort: not every filesystem supports it.
309pub fn fsync_dir(dir: &Path) {
310    if let Ok(d) = std::fs::File::open(dir) {
311        let _ = d.sync_all();
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::secrets::Recipient;
319
320    fn driver(dir: &Path) -> (LocalDriver, OrgId) {
321        let k = Keyring::new(age::x25519::Identity::generate(), vec![]);
322        (
323            LocalDriver::new(dir, Arc::new(k)),
324            OrgId::new("ocai").unwrap(),
325        )
326    }
327
328    fn mode(p: &Path) -> u32 {
329        std::fs::metadata(p).unwrap().permissions().mode() & 0o777
330    }
331
332    #[test]
333    fn round_trip_and_versions() {
334        let dir = tempfile::tempdir().unwrap();
335        let (d, org) = driver(dir.path());
336        let labels = BTreeMap::from([("team".to_string(), "web".to_string())]);
337        let m = d.create(&org, "db_password", b"one", &labels).unwrap();
338        assert_eq!((m.version, m.driver.as_str()), (1, "local"));
339        assert!(d.create(&org, "db_password", b"again", &labels).is_err());
340        assert_eq!(d.get(&org, "db_password").unwrap(), (b"one".to_vec(), 1));
341        assert_eq!(d.set(&org, "db_password", b"two").unwrap(), 2);
342        assert_eq!(d.set(&org, "db_password", b"three").unwrap(), 3);
343        assert_eq!(d.version(&org, "db_password").unwrap(), 3);
344        let (v, ver) = d.get(&org, "db_password").unwrap();
345        assert_eq!((v.as_slice(), ver), (&b"three"[..], 3));
346        let m = d.inspect(&org, "db_password").unwrap();
347        assert_eq!(m.labels, labels, "set keeps labels");
348        assert!(m.created_at <= m.updated_at);
349        // set creates a missing one.
350        assert_eq!(d.set(&org, "api_key", b"k").unwrap(), 1);
351        // Orgs do not see each other's.
352        let other = OrgId::new("norm").unwrap();
353        assert!(matches!(
354            d.get(&other, "db_password"),
355            Err(Error::NotFound(_))
356        ));
357        assert!(d.list(&other).unwrap().is_empty());
358        d.delete(&org, "db_password").unwrap();
359        assert!(matches!(
360            d.get(&org, "db_password"),
361            Err(Error::NotFound(_))
362        ));
363        assert!(matches!(
364            d.delete(&org, "db_password"),
365            Err(Error::NotFound(_))
366        ));
367        let sec = d.dir(&org);
368        assert!(!sec.join("db_password.age").exists());
369        assert!(!sec.join("db_password.json").exists());
370    }
371
372    #[test]
373    fn list_has_metadata_never_values() {
374        let dir = tempfile::tempdir().unwrap();
375        let (d, org) = driver(dir.path());
376        d.create(&org, "b", b"value-b-SENTINEL", &BTreeMap::new())
377            .unwrap();
378        d.create(&org, "a", b"value-a-SENTINEL", &BTreeMap::new())
379            .unwrap();
380        let l = d.list(&org).unwrap();
381        assert_eq!(
382            l.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
383            ["a", "b"]
384        );
385        let json = serde_json::to_string(&l).unwrap();
386        assert!(!json.contains("SENTINEL"));
387        // Nor is the value anywhere on disk in the clear.
388        for e in std::fs::read_dir(d.dir(&org)).unwrap() {
389            let b = std::fs::read(e.unwrap().path()).unwrap();
390            assert!(!String::from_utf8_lossy(&b).contains("SENTINEL"));
391        }
392    }
393
394    #[test]
395    fn files_and_modes() {
396        let dir = tempfile::tempdir().unwrap();
397        let (d, org) = driver(dir.path());
398        d.create(&org, "k", b"v", &BTreeMap::new()).unwrap();
399        d.set(&org, "k", b"v2").unwrap();
400        let sec = dir.path().join("orgs/ocai/secrets");
401        assert_eq!(sec, d.dir(&org));
402        assert_eq!(mode(&sec), 0o700);
403        assert_eq!(mode(&sec.join("k.age")), 0o600);
404        assert_eq!(mode(&sec.join("k.json")), 0o600);
405        // No temp files left behind.
406        let mut names: Vec<String> = std::fs::read_dir(&sec)
407            .unwrap()
408            .map(|e| e.unwrap().file_name().into_string().unwrap())
409            .collect();
410        names.sort();
411        assert_eq!(names, ["k.age", "k.json"]);
412        // The ciphertext is a plain age file.
413        let ct = std::fs::read(sec.join("k.age")).unwrap();
414        assert!(ct.starts_with(b"age-encryption.org/v1\n"));
415        // A stale temp file with loose permissions is tightened on reuse.
416        let tmp = sec.join("k.age.tmp");
417        std::fs::write(&tmp, b"junk").unwrap();
418        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
419        d.set(&org, "k", b"v3").unwrap();
420        assert!(!tmp.exists());
421        assert_eq!(mode(&sec.join("k.age")), 0o600);
422        // A directory created looser is tightened.
423        std::fs::set_permissions(&sec, std::fs::Permissions::from_mode(0o755)).unwrap();
424        d.set(&org, "k", b"v4").unwrap();
425        assert_eq!(mode(&sec), 0o700);
426    }
427
428    #[test]
429    fn reencrypt_to_an_added_recipient() {
430        let dir = tempfile::tempdir().unwrap();
431        let daemon = age::x25519::Identity::generate();
432        let daemon_text = crate::secrets::keys::identity_file_text(&daemon);
433        let k1 = Keyring::new(daemon, vec![]);
434        let d1 = LocalDriver::new(dir.path(), Arc::new(k1));
435        let org = OrgId::default_org();
436        let org2 = OrgId::new("norm").unwrap();
437        d1.create(&org, "a", b"alpha", &BTreeMap::new()).unwrap();
438        d1.create(&org2, "b", b"beta", &BTreeMap::new()).unwrap();
439        let glass = age::x25519::Identity::generate();
440        let read_with = |id: &dyn age::Identity, org: &OrgId, n: &str| {
441            let ct = std::fs::read(d1.dir(org).join(format!("{n}.age"))).unwrap();
442            crate::secrets::inline::decrypt(&ct, &[id])
443        };
444        assert!(read_with(&glass, &org, "a").is_err());
445
446        // Same daemon key, a break-glass recipient added in config.
447        let daemon = crate::secrets::keys::parse_identity(&daemon_text).unwrap();
448        let k2 = Keyring::new(daemon, vec![Recipient::X25519(glass.to_public())]);
449        let d2 = LocalDriver::new(dir.path(), Arc::new(k2));
450        assert_eq!(d2.reencrypt(Some(&org)).unwrap(), 1);
451        assert_eq!(read_with(&glass, &org, "a").unwrap(), b"alpha");
452        assert!(read_with(&glass, &org2, "b").is_err());
453        assert_eq!(d2.reencrypt(None).unwrap(), 2);
454        assert_eq!(read_with(&glass, &org2, "b").unwrap(), b"beta");
455        // The daemon still reads its own, and versions did not move.
456        assert_eq!(d2.get(&org, "a").unwrap(), (b"alpha".to_vec(), 1));
457        assert_eq!(d2.get(&org2, "b").unwrap(), (b"beta".to_vec(), 1));
458    }
459
460    #[test]
461    fn wrong_key_is_reported() {
462        let dir = tempfile::tempdir().unwrap();
463        let (d, org) = driver(dir.path());
464        d.create(&org, "k", b"v", &BTreeMap::new()).unwrap();
465        let (d2, _) = driver(dir.path());
466        let e = d2.get(&org, "k").unwrap_err().to_string();
467        assert!(e.contains("cannot decrypt"), "{e}");
468        // Listing still works without the key.
469        assert_eq!(d2.list(&org).unwrap().len(), 1);
470    }
471}