Skip to main content

isb_core/secrets/
mod.rs

1//! Secrets: named values per org, behind pluggable drivers.
2//!
3//! A secret is `(org, name)` with a value and a [`SecretMeta`] (driver,
4//! version, timestamps, labels). A [`Driver`] stores values; [`Secrets`] is
5//! the facade the daemon uses, and finds the driver holding each secret. The
6//! default driver is [`local`]: age ciphertext under the daemon's state
7//! directory, encrypted to the daemon's own key and any break-glass
8//! recipients ([`keys`]). Values are never listed: `list` and `inspect`
9//! return metadata only.
10//!
11//! Compose files can also carry a value inline, age-encrypted ([`inline`]).
12
13pub mod inline;
14pub mod keys;
15pub mod local;
16pub mod onepassword;
17
18use std::collections::BTreeMap;
19use std::path::Path;
20use std::sync::Arc;
21
22use serde::{Deserialize, Serialize};
23
24use crate::error::{Error, Result};
25use crate::org::OrgId;
26
27pub use inline::{decrypt_inline, encrypt_inline};
28pub use keys::{KeyOrigin, KeySources, Keyring, Recipient, SecretsConfig};
29pub use local::LocalDriver;
30
31/// The largest value a secret may hold.
32pub const MAX_VALUE_BYTES: usize = 1024 * 1024;
33
34/// What is known about a secret besides its value.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct SecretMeta {
37    pub org: OrgId,
38    pub name: String,
39    /// The driver holding the value (`local`, ...).
40    pub driver: String,
41    /// Starts at 1; every new value bumps it. Stacks roll on a change.
42    pub version: u64,
43    /// Unix seconds.
44    pub created_at: u64,
45    pub updated_at: u64,
46    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
47    pub labels: BTreeMap<String, String>,
48}
49
50/// A secret store. Drivers that cannot write (an external vault read through
51/// a read-only token) keep the default `create`/`set`/`delete`, which refuse.
52pub trait Driver: Send + Sync {
53    /// The name compose files and `--driver` use.
54    fn name(&self) -> &str;
55
56    /// The value and its version.
57    fn get(&self, org: &OrgId, name: &str) -> Result<(Vec<u8>, u64)>;
58
59    /// The current version only: cheap, for polling.
60    fn version(&self, org: &OrgId, name: &str) -> Result<u64>;
61
62    fn inspect(&self, org: &OrgId, name: &str) -> Result<SecretMeta>;
63
64    /// Every secret in the org this driver holds: metadata, never values.
65    fn list(&self, org: &OrgId) -> Result<Vec<SecretMeta>>;
66
67    /// A new secret at version 1; fails if it exists.
68    fn create(
69        &self,
70        org: &OrgId,
71        name: &str,
72        _value: &[u8],
73        _labels: &BTreeMap<String, String>,
74    ) -> Result<SecretMeta> {
75        Err(read_only(self.name(), org, name))
76    }
77
78    /// A new value; returns the new version. Creates the secret if missing.
79    fn set(&self, org: &OrgId, name: &str, _value: &[u8]) -> Result<u64> {
80        Err(read_only(self.name(), org, name))
81    }
82
83    fn delete(&self, org: &OrgId, name: &str) -> Result<()> {
84        Err(read_only(self.name(), org, name))
85    }
86
87    /// Re-read the value from its source now (external drivers cache and
88    /// poll). A no-op for drivers that are their own source.
89    fn refresh(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
90        self.inspect(org, name)
91    }
92
93    /// Re-encrypt every value (in one org, or all) to the current
94    /// recipients. Returns how many. A no-op for drivers that do not
95    /// encrypt.
96    fn reencrypt(&self, _org: Option<&OrgId>) -> Result<usize> {
97        Ok(0)
98    }
99}
100
101/// A valid secret name: 1-128 of `[A-Za-z0-9_.-]`, not starting with `.`
102/// (so it can never be `.`, `..` or a hidden file).
103pub fn validate_name(name: &str) -> Result<()> {
104    let ok = !name.is_empty()
105        && name.len() <= 128
106        && !name.starts_with('.')
107        && name
108            .bytes()
109            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'));
110    if ok {
111        Ok(())
112    } else {
113        Err(Error::invalid(format!(
114            "secret name {name:?}: 1-128 characters of [A-Za-z0-9_.-], not starting with '.'"
115        )))
116    }
117}
118
119#[doc(hidden)]
120pub fn not_found(org: &OrgId, name: &str) -> Error {
121    Error::NotFound(format!("secret {name} in org {org}"))
122}
123
124pub(crate) fn exists(org: &OrgId, name: &str) -> Error {
125    Error::invalid(format!(
126        "secret {name} already exists in org {org}; use `isb secret set` for a new value"
127    ))
128}
129
130fn read_only(driver: &str, org: &OrgId, name: &str) -> Error {
131    Error::invalid(format!(
132        "secret {name} in org {org}: driver {driver} is read-only"
133    ))
134}
135
136fn check_value(value: &[u8]) -> Result<()> {
137    if value.len() > MAX_VALUE_BYTES {
138        return Err(Error::invalid(format!(
139            "secret value is {} bytes; the limit is {MAX_VALUE_BYTES}",
140            value.len()
141        )));
142    }
143    Ok(())
144}
145
146/// The daemon's secrets: every driver, `local` first and the default.
147pub struct Secrets {
148    drivers: Vec<Arc<dyn Driver>>,
149    keyring: Arc<Keyring>,
150}
151
152/// What [`Secrets::open`] found, for the daemon to log.
153pub struct Opened {
154    pub secrets: Secrets,
155    pub origin: KeyOrigin,
156    /// One line each: where the key came from, a generated key, warnings.
157    pub notes: Vec<String>,
158}
159
160impl Secrets {
161    /// Just the `local` driver.
162    pub fn new(local: LocalDriver) -> Secrets {
163        let keyring = local.keyring().clone();
164        Secrets {
165            drivers: vec![Arc::new(local)],
166            keyring,
167        }
168    }
169
170    /// Find (or generate) the daemon's key, read the break-glass recipients,
171    /// and open the local store under `state_dir`.
172    pub fn open(state_dir: &Path, keys: &KeySources, config: &SecretsConfig) -> Result<Opened> {
173        let break_glass = config.parsed_recipients()?;
174        let k = keys::load_identity(keys)?;
175        let keyring = Arc::new(Keyring::new(k.identity, break_glass));
176        let mut notes = vec![format!(
177            "secrets key from {} (public key {})",
178            k.origin,
179            keyring.public_key()
180        )];
181        notes.extend(k.notes);
182        if keyring.break_glass().is_empty() {
183            notes.push(format!(
184                "WARNING: no break-glass recipients: losing the secrets key loses every secret. Add recipients = [\"age1...\" or \"ssh-ed25519 ...\"] to {}, then run `isb secret reencrypt --all`",
185                SecretsConfig::default_path().display()
186            ));
187        }
188        Ok(Opened {
189            secrets: Secrets::new(LocalDriver::new(state_dir, keyring)),
190            origin: k.origin,
191            notes,
192        })
193    }
194
195    /// Open the store with an existing key only, for a client reading it
196    /// while no daemon runs (`isb up`). Never generates a key.
197    pub fn open_existing(
198        state_dir: &Path,
199        keys: &KeySources,
200        config: &SecretsConfig,
201    ) -> Result<Secrets> {
202        let break_glass = config.parsed_recipients()?;
203        let k = keys::find_identity(keys)?;
204        let keyring = Arc::new(Keyring::new(k.identity, break_glass));
205        Ok(Secrets::new(LocalDriver::new(state_dir, keyring)))
206    }
207
208    /// Add a driver. Names are unique.
209    pub fn with_driver(mut self, d: Arc<dyn Driver>) -> Result<Secrets> {
210        if self.drivers.iter().any(|x| x.name() == d.name()) {
211            return Err(Error::invalid(format!(
212                "secrets driver {} registered twice",
213                d.name()
214            )));
215        }
216        self.drivers.push(d);
217        Ok(self)
218    }
219
220    pub fn driver_names(&self) -> Vec<String> {
221        self.drivers.iter().map(|d| d.name().to_string()).collect()
222    }
223
224    pub fn keyring(&self) -> &Arc<Keyring> {
225        &self.keyring
226    }
227
228    /// The recipient set, as strings (`age1…`, `ssh-…`).
229    pub fn recipients(&self) -> Vec<String> {
230        self.keyring
231            .recipients()
232            .iter()
233            .map(|r| r.to_string())
234            .collect()
235    }
236
237    fn driver(&self, name: &str) -> Result<&Arc<dyn Driver>> {
238        self.drivers
239            .iter()
240            .find(|d| d.name() == name)
241            .ok_or_else(|| {
242                Error::invalid(format!(
243                    "unknown secrets driver {name:?} (have: {})",
244                    self.driver_names().join(", ")
245                ))
246            })
247    }
248
249    /// The driver holding a secret, and its metadata.
250    fn holder(&self, org: &OrgId, name: &str) -> Result<(&Arc<dyn Driver>, SecretMeta)> {
251        // A name with a `/` is a reference into an external store
252        // (`vault/item/field`): only external drivers can hold it.
253        let reference = name.contains('/');
254        if !reference {
255            validate_name(name)?;
256        }
257        for d in &self.drivers {
258            if reference && d.name() == local::DRIVER {
259                continue;
260            }
261            match d.inspect(org, name) {
262                Ok(m) => return Ok((d, m)),
263                Err(Error::NotFound(_)) => {}
264                Err(e) => return Err(e),
265            }
266        }
267        Err(not_found(org, name))
268    }
269
270    /// A new secret in `driver` (default `local`); fails if any driver has
271    /// one by that name.
272    pub fn create(
273        &self,
274        org: &OrgId,
275        name: &str,
276        driver: Option<&str>,
277        value: &[u8],
278        labels: &BTreeMap<String, String>,
279    ) -> Result<SecretMeta> {
280        validate_name(name)?;
281        check_value(value)?;
282        for k in labels.keys() {
283            if k.is_empty() || k.len() > 128 {
284                return Err(Error::invalid(format!("label key {k:?}: 1-128 characters")));
285            }
286        }
287        let d = self.driver(driver.unwrap_or(local::DRIVER))?;
288        match self.holder(org, name) {
289            Ok(_) => return Err(exists(org, name)),
290            Err(Error::NotFound(_)) => {}
291            Err(e) => return Err(e),
292        }
293        d.create(org, name, value, labels)
294    }
295
296    /// A new value for a secret, in the driver holding it; a missing one is
297    /// created in `local`.
298    pub fn set(&self, org: &OrgId, name: &str, value: &[u8]) -> Result<SecretMeta> {
299        check_value(value)?;
300        let d = match self.holder(org, name) {
301            Ok((d, _)) => d,
302            Err(Error::NotFound(_)) => self.driver(local::DRIVER)?,
303            Err(e) => return Err(e),
304        };
305        d.set(org, name, value)?;
306        d.inspect(org, name)
307    }
308
309    pub fn get(&self, org: &OrgId, name: &str) -> Result<(Vec<u8>, SecretMeta)> {
310        let (d, _) = self.holder(org, name)?;
311        let (v, version) = d.get(org, name)?;
312        let mut m = d.inspect(org, name)?;
313        // The value read is the one to describe, even if a write landed
314        // between the two calls.
315        m.version = version;
316        Ok((v, m))
317    }
318
319    pub fn version(&self, org: &OrgId, name: &str) -> Result<u64> {
320        let (d, _) = self.holder(org, name)?;
321        d.version(org, name)
322    }
323
324    /// Store `value` under `name` unless it already holds exactly that, so
325    /// the version moves only when the value does.
326    pub fn put(&self, org: &OrgId, name: &str, value: &[u8]) -> Result<SecretMeta> {
327        match self.get(org, name) {
328            Ok((v, m)) if v == value => return Ok(m),
329            Ok(_) | Err(Error::NotFound(_)) => {}
330            Err(e) => return Err(e),
331        }
332        self.set(org, name, value)
333    }
334
335    /// A value read through a named driver, by that driver's reference
336    /// (which need not be a store name: an `op://` path, say).
337    pub fn get_in(&self, driver: &str, org: &OrgId, name: &str) -> Result<(Vec<u8>, u64)> {
338        self.driver(driver)?.get(org, name)
339    }
340
341    /// The current version in a named driver: cheap, for polling.
342    pub fn version_in(&self, driver: &str, org: &OrgId, name: &str) -> Result<u64> {
343        self.driver(driver)?.version(org, name)
344    }
345
346    /// Re-read from the source through a named driver.
347    pub fn refresh_in(&self, driver: &str, org: &OrgId, name: &str) -> Result<u64> {
348        let d = self.driver(driver)?;
349        d.refresh(org, name)?;
350        d.version(org, name)
351    }
352
353    pub fn inspect(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
354        self.holder(org, name).map(|(_, m)| m)
355    }
356
357    /// Every driver's secrets in the org, by name.
358    pub fn list(&self, org: &OrgId) -> Result<Vec<SecretMeta>> {
359        let mut out = Vec::new();
360        for d in &self.drivers {
361            out.extend(d.list(org)?);
362        }
363        out.sort_by(|a, b| a.name.cmp(&b.name));
364        Ok(out)
365    }
366
367    pub fn delete(&self, org: &OrgId, name: &str) -> Result<()> {
368        let (d, _) = self.holder(org, name)?;
369        d.delete(org, name)
370    }
371
372    pub fn refresh(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
373        let (d, _) = self.holder(org, name)?;
374        d.refresh(org, name)
375    }
376
377    /// Re-encrypt every value to the current recipients, in one org or all.
378    pub fn reencrypt(&self, org: Option<&OrgId>) -> Result<usize> {
379        let mut n = 0;
380        for d in &self.drivers {
381            n += d.reencrypt(org)?;
382        }
383        Ok(n)
384    }
385
386    /// Decrypt a compose file's inline `age:` value with the daemon's key.
387    pub fn decrypt_inline(&self, text: &str) -> Result<Vec<u8>> {
388        inline::decrypt_inline(text, &[self.keyring.identity()])
389    }
390
391    /// Inline ciphertext to the daemon's recipients.
392    pub fn encrypt_inline(&self, value: &[u8]) -> Result<String> {
393        inline::encrypt_inline(value, self.keyring.recipients())
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    /// A read-only driver holding one fixed secret, as an external vault
402    /// would.
403    struct Fixed;
404    impl Driver for Fixed {
405        fn name(&self) -> &str {
406            "fixed"
407        }
408        fn get(&self, org: &OrgId, name: &str) -> Result<(Vec<u8>, u64)> {
409            self.inspect(org, name)
410                .map(|m| (b"external".to_vec(), m.version))
411        }
412        fn version(&self, org: &OrgId, name: &str) -> Result<u64> {
413            self.inspect(org, name).map(|m| m.version)
414        }
415        fn inspect(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
416            if name != "vault_token" {
417                return Err(not_found(org, name));
418            }
419            Ok(SecretMeta {
420                org: org.clone(),
421                name: name.into(),
422                driver: "fixed".into(),
423                version: 7,
424                created_at: 0,
425                updated_at: 0,
426                labels: BTreeMap::new(),
427            })
428        }
429        fn list(&self, org: &OrgId) -> Result<Vec<SecretMeta>> {
430            Ok(vec![self.inspect(org, "vault_token")?])
431        }
432    }
433
434    fn secrets(dir: &Path) -> Secrets {
435        let k = Keyring::new(age::x25519::Identity::generate(), vec![]);
436        Secrets::new(LocalDriver::new(dir, Arc::new(k)))
437            .with_driver(Arc::new(Fixed))
438            .unwrap()
439    }
440
441    #[test]
442    fn names() {
443        for ok in ["a", "DB_PASSWORD", "tls.key", "x-1", &"n".repeat(128)] {
444            assert!(validate_name(ok).is_ok(), "{ok}");
445        }
446        for bad in [
447            "",
448            ".",
449            "..",
450            ".hidden",
451            "a/b",
452            "../x",
453            "a b",
454            "ü",
455            &"n".repeat(129),
456        ] {
457            assert!(validate_name(bad).is_err(), "{bad:?}");
458        }
459    }
460
461    #[test]
462    fn facade_dispatches_per_secret() {
463        let dir = tempfile::tempdir().unwrap();
464        let s = secrets(dir.path());
465        let org = OrgId::default_org();
466        assert_eq!(s.driver_names(), ["local", "fixed"]);
467        s.create(&org, "db", None, b"pw", &BTreeMap::new()).unwrap();
468        // Names are unique across drivers.
469        assert!(
470            s.create(&org, "vault_token", None, b"x", &BTreeMap::new())
471                .is_err()
472        );
473        assert!(
474            s.create(&org, "new", Some("nope"), b"x", &BTreeMap::new())
475                .is_err()
476        );
477        // A read-only driver refuses writes.
478        let e = s
479            .create(&org, "other", Some("fixed"), b"x", &BTreeMap::new())
480            .unwrap_err();
481        assert!(e.to_string().contains("read-only"), "{e}");
482        assert!(s.set(&org, "vault_token", b"x").is_err());
483        assert!(s.delete(&org, "vault_token").is_err());
484        let (v, m) = s.get(&org, "vault_token").unwrap();
485        assert_eq!((v.as_slice(), m.version), (&b"external"[..], 7));
486        let (v, m) = s.get(&org, "db").unwrap();
487        assert_eq!(
488            (v.as_slice(), m.version, m.driver.as_str()),
489            (&b"pw"[..], 1, "local")
490        );
491        assert_eq!(s.set(&org, "db", b"pw2").unwrap().version, 2);
492        assert_eq!(s.version(&org, "db").unwrap(), 2);
493        assert_eq!(s.refresh(&org, "db").unwrap().version, 2);
494        // put: the version moves only with the value.
495        assert_eq!(s.put(&org, "db", b"pw2").unwrap().version, 2);
496        assert_eq!(s.put(&org, "db", b"pw3").unwrap().version, 3);
497        assert_eq!(s.put(&org, "fresh", b"x").unwrap().version, 1);
498        s.delete(&org, "fresh").unwrap();
499        s.set(&org, "db", b"pw2").unwrap();
500        assert_eq!(s.version_in("local", &org, "db").unwrap(), 4);
501        assert_eq!(s.get_in("fixed", &org, "vault_token").unwrap().1, 7);
502        assert!(s.version_in("nope", &org, "db").is_err());
503        s.set(&org, "db", b"pw2").unwrap();
504        s.set(&org, "db", b"pw2").unwrap();
505        assert_eq!(s.version(&org, "db").unwrap(), 6);
506        // Back to where the rest of the test expects it.
507        s.delete(&org, "db").unwrap();
508        s.create(&org, "db", None, b"pw2", &BTreeMap::new())
509            .unwrap();
510        s.set(&org, "db", b"pw2").unwrap();
511        let names: Vec<_> = s.list(&org).unwrap().into_iter().map(|m| m.name).collect();
512        assert_eq!(names, ["db", "vault_token"]);
513        assert!(s.set(&org, "big", &vec![0; MAX_VALUE_BYTES + 1]).is_err());
514        s.delete(&org, "db").unwrap();
515        assert!(matches!(s.inspect(&org, "db"), Err(Error::NotFound(_))));
516        assert!(s.with_driver(Arc::new(Fixed)).is_err());
517    }
518
519    #[test]
520    fn open_generates_and_warns() {
521        let dir = tempfile::tempdir().unwrap();
522        let keys = KeySources {
523            default_file: dir.path().join("cfg/age.txt"),
524            ..Default::default()
525        };
526        let o = Secrets::open(&dir.path().join("state"), &keys, &SecretsConfig::default()).unwrap();
527        assert!(matches!(o.origin, KeyOrigin::Generated(_)));
528        assert!(o.notes.iter().any(|n| n.contains("no break-glass")));
529        let org = OrgId::default_org();
530        o.secrets
531            .create(&org, "k", None, b"v", &BTreeMap::new())
532            .unwrap();
533        let ssh = include_str!("testdata/break_glass_ed25519.pub")
534            .trim()
535            .to_string();
536        let cfg = SecretsConfig {
537            recipients: vec![ssh],
538        };
539        let o2 = Secrets::open(&dir.path().join("state"), &keys, &cfg).unwrap();
540        assert!(matches!(o2.origin, KeyOrigin::DefaultFile(_)));
541        assert!(!o2.notes.iter().any(|n| n.contains("no break-glass")));
542        assert_eq!(o2.secrets.recipients().len(), 2);
543        assert_eq!(o2.secrets.get(&org, "k").unwrap().0, b"v");
544        let a = o2.secrets.encrypt_inline(b"inline").unwrap();
545        assert_eq!(o2.secrets.decrypt_inline(&a).unwrap(), b"inline");
546        let bad = SecretsConfig {
547            recipients: vec!["nope".into()],
548        };
549        assert!(Secrets::open(&dir.path().join("state"), &keys, &bad).is_err());
550    }
551}