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