Skip to main content

isb_core/secrets/
keys.rs

1//! The daemon's age identity, the break-glass recipients, and where both
2//! come from.
3//!
4//! Key lookup, first hit wins: `ISB_AGE_KEY` (the key itself), the systemd
5//! credential `$CREDENTIALS_DIRECTORY/isb-age-key`, `ISB_AGE_KEY_FILE`, then
6//! `~/.config/isb/age.txt`. With none of them, a new X25519 identity is
7//! generated at that last path.
8
9use std::fmt;
10use std::io::Write;
11use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
12use std::path::{Path, PathBuf};
13use std::str::FromStr;
14
15use age::secrecy::ExposeSecret;
16use serde::{Deserialize, Serialize};
17
18use crate::error::{Error, Result};
19
20/// The systemd credential name (`LoadCredential=`/`SetCredentialEncrypted=`).
21pub const CREDENTIAL_NAME: &str = "isb-age-key";
22
23/// Something a value is encrypted to: an age X25519 key (`age1…`) or an SSH
24/// public key (`ssh-ed25519 …`, `ssh-rsa …`).
25#[derive(Clone)]
26pub enum Recipient {
27    X25519(age::x25519::Recipient),
28    Ssh(age::ssh::Recipient),
29}
30
31impl Recipient {
32    /// Parse one recipient. An SSH key's trailing comment is ignored.
33    pub fn parse(s: &str) -> Result<Recipient> {
34        let s = s.trim();
35        if s.starts_with("age1") {
36            return age::x25519::Recipient::from_str(s)
37                .map(Recipient::X25519)
38                .map_err(|e| Error::invalid(format!("recipient {s:?}: {e}")));
39        }
40        if s.starts_with("ssh-") {
41            // `type base64 [comment]`: the comment is not part of the key.
42            let key: Vec<&str> = s.split_whitespace().take(2).collect();
43            let key = key.join(" ");
44            return age::ssh::Recipient::from_str(&key)
45                .map(Recipient::Ssh)
46                .map_err(|e| Error::invalid(format!("recipient {}: {e:?}", brief(&key))));
47        }
48        Err(Error::invalid(format!(
49            "recipient {}: expected an age key (age1...) or an ssh-ed25519/ssh-rsa public key",
50            brief(s)
51        )))
52    }
53
54    pub fn as_age(&self) -> &dyn age::Recipient {
55        match self {
56            Recipient::X25519(r) => r,
57            Recipient::Ssh(r) => r,
58        }
59    }
60}
61
62impl fmt::Display for Recipient {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Recipient::X25519(r) => write!(f, "{r}"),
66            Recipient::Ssh(r) => write!(f, "{r}"),
67        }
68    }
69}
70
71impl fmt::Debug for Recipient {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(f, "Recipient({self})")
74    }
75}
76
77impl FromStr for Recipient {
78    type Err = Error;
79    fn from_str(s: &str) -> Result<Recipient> {
80        Recipient::parse(s)
81    }
82}
83
84/// The first 24 characters, for error messages about keys.
85fn brief(s: &str) -> String {
86    let t: String = s.chars().take(24).collect();
87    if t.len() < s.len() {
88        format!("{t:?}...")
89    } else {
90        format!("{t:?}")
91    }
92}
93
94/// Parse an identity: the first `AGE-SECRET-KEY-1…` line of an age key file
95/// (`age-keygen` output, comments allowed) or the bare key.
96pub fn parse_identity(text: &str) -> Result<age::x25519::Identity> {
97    let line = text
98        .lines()
99        .map(str::trim)
100        .find(|l| l.starts_with("AGE-SECRET-KEY-"))
101        .ok_or_else(|| Error::invalid("no AGE-SECRET-KEY-1... line in the age key"))?;
102    age::x25519::Identity::from_str(line)
103        .map_err(|e| Error::invalid(format!("invalid age secret key: {e}")))
104}
105
106/// `age-keygen`'s file format, so `age -d -i` can use the file directly.
107pub fn identity_file_text(id: &age::x25519::Identity) -> String {
108    format!(
109        "# isb serve's secrets key. Keep it out of unencrypted backups.\n# public key: {}\n{}\n",
110        id.to_public(),
111        id.to_string().expose_secret()
112    )
113}
114
115/// `$XDG_CONFIG_HOME/isb`, else `~/.config/isb`.
116pub fn config_dir() -> PathBuf {
117    std::env::var_os("XDG_CONFIG_HOME")
118        .filter(|s| !s.is_empty())
119        .map(PathBuf::from)
120        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
121        .unwrap_or_else(|| PathBuf::from("/etc"))
122        .join("isb")
123}
124
125/// Where the key is looked for, in order. Injectable for tests.
126#[derive(Clone, Default)]
127pub struct KeySources {
128    /// `ISB_AGE_KEY`: the key itself.
129    pub key: Option<String>,
130    /// `$CREDENTIALS_DIRECTORY`, where systemd puts `isb-age-key`.
131    pub credentials_dir: Option<PathBuf>,
132    /// `ISB_AGE_KEY_FILE`: must exist when set.
133    pub key_file: Option<PathBuf>,
134    /// `~/.config/isb/age.txt`: read when present, else generated.
135    pub default_file: PathBuf,
136}
137
138impl fmt::Debug for KeySources {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        f.debug_struct("KeySources")
141            .field("key", &self.key.as_ref().map(|_| "<redacted>"))
142            .field("credentials_dir", &self.credentials_dir)
143            .field("key_file", &self.key_file)
144            .field("default_file", &self.default_file)
145            .finish()
146    }
147}
148
149impl KeySources {
150    pub fn from_env() -> KeySources {
151        let var = |k: &str| std::env::var(k).ok().filter(|s| !s.is_empty());
152        KeySources {
153            key: var("ISB_AGE_KEY"),
154            credentials_dir: var("CREDENTIALS_DIRECTORY").map(PathBuf::from),
155            key_file: var("ISB_AGE_KEY_FILE").map(PathBuf::from),
156            default_file: config_dir().join("age.txt"),
157        }
158    }
159}
160
161/// Where the key came from.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum KeyOrigin {
164    Env,
165    Credential(PathBuf),
166    KeyFile(PathBuf),
167    DefaultFile(PathBuf),
168    /// Just generated, at this path.
169    Generated(PathBuf),
170}
171
172impl fmt::Display for KeyOrigin {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        match self {
175            KeyOrigin::Env => f.write_str("$ISB_AGE_KEY"),
176            KeyOrigin::Credential(p) => write!(f, "systemd credential {}", p.display()),
177            KeyOrigin::KeyFile(p) => write!(f, "$ISB_AGE_KEY_FILE {}", p.display()),
178            KeyOrigin::DefaultFile(p) => write!(f, "{}", p.display()),
179            KeyOrigin::Generated(p) => write!(f, "{} (generated)", p.display()),
180        }
181    }
182}
183
184/// The daemon's identity, where it came from, and anything worth logging.
185pub struct LoadedKey {
186    pub identity: age::x25519::Identity,
187    pub origin: KeyOrigin,
188    pub notes: Vec<String>,
189}
190
191/// Find the daemon's key, or generate one at `default_file`.
192pub fn load_identity(src: &KeySources) -> Result<LoadedKey> {
193    lookup_identity(src, true)
194}
195
196/// Find the daemon's key; never generate one (a client reading the store
197/// must not mint a key the daemon would then use).
198pub fn find_identity(src: &KeySources) -> Result<LoadedKey> {
199    lookup_identity(src, false)
200}
201
202fn lookup_identity(src: &KeySources, generate: bool) -> Result<LoadedKey> {
203    let loaded = |identity, origin| LoadedKey {
204        identity,
205        origin,
206        notes: Vec::new(),
207    };
208    if let Some(k) = src.key.as_deref().filter(|k| !k.trim().is_empty()) {
209        let id = parse_identity(k).map_err(|e| Error::invalid(format!("ISB_AGE_KEY: {e}")))?;
210        return Ok(loaded(id, KeyOrigin::Env));
211    }
212    if let Some(dir) = &src.credentials_dir {
213        // The unit may carry other credentials; only ours counts.
214        let p = dir.join(CREDENTIAL_NAME);
215        if p.exists() {
216            return Ok(loaded(read_identity(&p)?, KeyOrigin::Credential(p)));
217        }
218    }
219    if let Some(p) = &src.key_file {
220        // Named explicitly: missing is an error, not a reason to generate.
221        return Ok(loaded(read_identity(p)?, KeyOrigin::KeyFile(p.clone())));
222    }
223    let p = &src.default_file;
224    if p.exists() {
225        let mut k = loaded(read_identity(p)?, KeyOrigin::DefaultFile(p.clone()));
226        if let Ok(m) = std::fs::metadata(p) {
227            if m.permissions().mode() & 0o077 != 0 {
228                k.notes.push(format!(
229                    "WARNING: {} is readable by others (mode {:o}); chmod 600 it",
230                    p.display(),
231                    m.permissions().mode() & 0o777
232                ));
233            }
234        }
235        return Ok(k);
236    }
237    if !generate {
238        return Err(Error::invalid(format!(
239            "no secrets key: not in $ISB_AGE_KEY, $CREDENTIALS_DIRECTORY/{CREDENTIAL_NAME}, $ISB_AGE_KEY_FILE or {}",
240            p.display()
241        )));
242    }
243    let id = generate_identity_file(p)?;
244    let mut k = loaded(id, KeyOrigin::Generated(p.clone()));
245    k.notes.push(format!(
246        "generated a new secrets key at {}: exclude it from unencrypted backups, and add a break-glass recipient (recipients = [...] in {}) so secrets survive losing it",
247        p.display(),
248        SecretsConfig::default_path().display()
249    ));
250    Ok(k)
251}
252
253fn read_identity(p: &Path) -> Result<age::x25519::Identity> {
254    let text = std::fs::read_to_string(p)
255        .map_err(|e| Error::invalid(format!("age key {}: {e}", p.display())))?;
256    parse_identity(&text).map_err(|e| Error::invalid(format!("age key {}: {e}", p.display())))
257}
258
259/// Write a new identity to `path` (0600, its directory 0700) without ever
260/// replacing an existing file: written to a temp file, then hard-linked in.
261pub fn generate_identity_file(path: &Path) -> Result<age::x25519::Identity> {
262    let dir = path
263        .parent()
264        .ok_or_else(|| Error::invalid(format!("{}: no parent directory", path.display())))?;
265    std::fs::DirBuilder::new()
266        .recursive(true)
267        .mode(0o700)
268        .create(dir)?;
269    let id = age::x25519::Identity::generate();
270    let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
271    let r = (|| -> Result<()> {
272        let mut f = std::fs::OpenOptions::new()
273            .write(true)
274            .create_new(true)
275            .mode(0o600)
276            .open(&tmp)?;
277        f.write_all(identity_file_text(&id).as_bytes())?;
278        f.sync_all()?;
279        std::fs::hard_link(&tmp, path).map_err(|e| {
280            Error::invalid(format!("cannot create age key {}: {e}", path.display()))
281        })?;
282        Ok(())
283    })();
284    let _ = std::fs::remove_file(&tmp);
285    r?;
286    super::local::fsync_dir(dir);
287    Ok(id)
288}
289
290/// `~/.config/isb/secrets.toml`: break-glass recipients.
291///
292/// ```toml
293/// recipients = [
294///   "age1...",
295///   "ssh-ed25519 AAAA... ops@example.com",
296/// ]
297/// ```
298#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct SecretsConfig {
301    /// Every value is also encrypted to these, so it can be recovered
302    /// without the daemon's key.
303    #[serde(default)]
304    pub recipients: Vec<String>,
305}
306
307impl SecretsConfig {
308    /// `$ISB_SECRETS_CONFIG`, else `~/.config/isb/secrets.toml`.
309    pub fn default_path() -> PathBuf {
310        std::env::var_os("ISB_SECRETS_CONFIG")
311            .filter(|s| !s.is_empty())
312            .map(PathBuf::from)
313            .unwrap_or_else(|| config_dir().join("secrets.toml"))
314    }
315
316    /// Load the file; a missing file is an empty config.
317    pub fn load(path: &Path) -> Result<SecretsConfig> {
318        match std::fs::read_to_string(path) {
319            Ok(text) => SecretsConfig::parse(&text).map_err(|e| Error::Parse {
320                path: path.display().to_string(),
321                message: e.to_string(),
322            }),
323            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(SecretsConfig::default()),
324            Err(e) => Err(Error::invalid(format!("{}: {e}", path.display()))),
325        }
326    }
327
328    pub fn parse(text: &str) -> Result<SecretsConfig> {
329        let c: SecretsConfig = toml::from_str(text).map_err(|e| Error::invalid(e.to_string()))?;
330        c.parsed_recipients()?;
331        Ok(c)
332    }
333
334    pub fn parsed_recipients(&self) -> Result<Vec<Recipient>> {
335        self.recipients
336            .iter()
337            .map(|r| Recipient::parse(r))
338            .collect()
339    }
340}
341
342/// The daemon's identity and the full recipient set: its own public key
343/// first, then the break-glass recipients.
344pub struct Keyring {
345    identity: age::x25519::Identity,
346    recipients: Vec<Recipient>,
347}
348
349impl Keyring {
350    pub fn new(identity: age::x25519::Identity, break_glass: Vec<Recipient>) -> Keyring {
351        let mut recipients = vec![Recipient::X25519(identity.to_public())];
352        for r in break_glass {
353            if !recipients.iter().any(|x| x.to_string() == r.to_string()) {
354                recipients.push(r);
355            }
356        }
357        Keyring {
358            identity,
359            recipients,
360        }
361    }
362
363    /// The daemon's public key (`age1…`).
364    pub fn public_key(&self) -> String {
365        self.identity.to_public().to_string()
366    }
367
368    /// Everything a value is encrypted to.
369    pub fn recipients(&self) -> &[Recipient] {
370        &self.recipients
371    }
372
373    pub fn break_glass(&self) -> &[Recipient] {
374        &self.recipients[1..]
375    }
376
377    pub fn identity(&self) -> &dyn age::Identity {
378        &self.identity
379    }
380
381    /// Binary age ciphertext to every recipient.
382    pub fn encrypt(&self, value: &[u8]) -> Result<Vec<u8>> {
383        super::inline::encrypt(value, &self.recipients)
384    }
385
386    pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
387        super::inline::decrypt(ciphertext, &[&self.identity])
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    pub(crate) const SSH_PUB: &str = include_str!("testdata/break_glass_ed25519.pub");
396
397    fn sources(dir: &Path) -> KeySources {
398        KeySources {
399            default_file: dir.join("cfg/isb/age.txt"),
400            ..Default::default()
401        }
402    }
403
404    #[test]
405    fn recipients_parse() {
406        let id = age::x25519::Identity::generate();
407        let pk = id.to_public().to_string();
408        assert_eq!(Recipient::parse(&pk).unwrap().to_string(), pk);
409        let r = Recipient::parse(SSH_PUB.trim()).unwrap();
410        // The comment is dropped.
411        assert!(r.to_string().starts_with("ssh-ed25519 AAAA"));
412        assert!(!r.to_string().contains("isb-test"));
413        assert!(Recipient::parse("age1nope").is_err());
414        assert!(Recipient::parse("pgp:xyz").is_err());
415        assert!(Recipient::parse("ssh-ed25519 AAAAnotbase64!").is_err());
416    }
417
418    #[test]
419    fn config_parses_and_rejects() {
420        let pk = age::x25519::Identity::generate().to_public().to_string();
421        let c = SecretsConfig::parse(&format!(
422            "recipients = [\"{pk}\", \"{}\"]\n",
423            SSH_PUB.trim()
424        ))
425        .unwrap();
426        assert_eq!(c.parsed_recipients().unwrap().len(), 2);
427        assert!(SecretsConfig::parse("recipients = [\"bogus\"]").is_err());
428        assert!(SecretsConfig::parse("recipient = []").is_err());
429        let dir = tempfile::tempdir().unwrap();
430        assert_eq!(
431            SecretsConfig::load(&dir.path().join("none.toml")).unwrap(),
432            SecretsConfig::default()
433        );
434    }
435
436    #[test]
437    fn lookup_order() {
438        let dir = tempfile::tempdir().unwrap();
439        let ids: Vec<_> = (0..4).map(|_| age::x25519::Identity::generate()).collect();
440        let text = |i: usize| identity_file_text(&ids[i]);
441        let pk = |i: usize| ids[i].to_public().to_string();
442        let creds = dir.path().join("creds");
443        std::fs::create_dir_all(&creds).unwrap();
444        std::fs::write(creds.join(CREDENTIAL_NAME), text(1)).unwrap();
445        let kf = dir.path().join("key.txt");
446        std::fs::write(&kf, text(2)).unwrap();
447        let mut src = sources(dir.path());
448        std::fs::create_dir_all(src.default_file.parent().unwrap()).unwrap();
449        std::fs::write(&src.default_file, text(3)).unwrap();
450        src.key = Some(text(0));
451        src.credentials_dir = Some(creds.clone());
452        src.key_file = Some(kf.clone());
453
454        let k = load_identity(&src).unwrap();
455        assert_eq!(
456            (k.identity.to_public().to_string(), k.origin),
457            (pk(0), KeyOrigin::Env)
458        );
459        src.key = None;
460        let k = load_identity(&src).unwrap();
461        assert_eq!(k.identity.to_public().to_string(), pk(1));
462        assert_eq!(k.origin, KeyOrigin::Credential(creds.join(CREDENTIAL_NAME)));
463        // A credentials directory without our credential falls through.
464        std::fs::remove_file(creds.join(CREDENTIAL_NAME)).unwrap();
465        let k = load_identity(&src).unwrap();
466        assert_eq!(k.identity.to_public().to_string(), pk(2));
467        assert_eq!(k.origin, KeyOrigin::KeyFile(kf.clone()));
468        src.key_file = None;
469        let k = load_identity(&src).unwrap();
470        assert_eq!(k.identity.to_public().to_string(), pk(3));
471        assert_eq!(k.origin, KeyOrigin::DefaultFile(src.default_file.clone()));
472        // A named key file that is missing is an error, never a new key.
473        src.key_file = Some(dir.path().join("missing.txt"));
474        assert!(load_identity(&src).is_err());
475        // So is a bad ISB_AGE_KEY.
476        src.key = Some("AGE-SECRET-KEY-1NOPE".into());
477        assert!(
478            load_identity(&src)
479                .err()
480                .unwrap()
481                .to_string()
482                .contains("ISB_AGE_KEY")
483        );
484        // The bare key works as well as the file format.
485        let bare = ids[0].to_string().expose_secret().to_string();
486        src.key = Some(bare);
487        assert_eq!(
488            load_identity(&src)
489                .unwrap()
490                .identity
491                .to_public()
492                .to_string(),
493            pk(0)
494        );
495    }
496
497    #[test]
498    fn find_never_generates() {
499        let dir = tempfile::tempdir().unwrap();
500        let src = sources(dir.path());
501        let e = find_identity(&src).err().unwrap().to_string();
502        assert!(e.contains("no secrets key"), "{e}");
503        assert!(!src.default_file.exists());
504        let k = load_identity(&src).unwrap();
505        assert_eq!(
506            find_identity(&src)
507                .unwrap()
508                .identity
509                .to_public()
510                .to_string(),
511            k.identity.to_public().to_string()
512        );
513    }
514
515    #[test]
516    fn generates_once_with_private_modes() {
517        let dir = tempfile::tempdir().unwrap();
518        let src = sources(dir.path());
519        let k = load_identity(&src).unwrap();
520        assert_eq!(k.origin, KeyOrigin::Generated(src.default_file.clone()));
521        assert!(k.notes[0].contains("break-glass"));
522        let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
523        assert_eq!(mode(&src.default_file), 0o600);
524        assert_eq!(mode(src.default_file.parent().unwrap()), 0o700);
525        let text = std::fs::read_to_string(&src.default_file).unwrap();
526        assert!(text.contains(&format!("# public key: {}", k.identity.to_public())));
527        // The second start reads it back; nothing is regenerated.
528        let k2 = load_identity(&src).unwrap();
529        assert_eq!(k2.origin, KeyOrigin::DefaultFile(src.default_file.clone()));
530        assert_eq!(
531            k2.identity.to_public().to_string(),
532            k.identity.to_public().to_string()
533        );
534        // And generating over an existing file refuses.
535        assert!(generate_identity_file(&src.default_file).is_err());
536        // Group-readable gets a warning.
537        std::fs::set_permissions(&src.default_file, std::fs::Permissions::from_mode(0o640))
538            .unwrap();
539        assert!(load_identity(&src).unwrap().notes[0].contains("WARNING"));
540    }
541
542    #[test]
543    fn keyring_dedupes_and_round_trips() {
544        let id = age::x25519::Identity::generate();
545        let own = Recipient::X25519(id.to_public());
546        let ssh = Recipient::parse(SSH_PUB).unwrap();
547        let k = Keyring::new(id, vec![own, ssh.clone(), ssh]);
548        assert_eq!(k.recipients().len(), 2);
549        assert_eq!(k.break_glass().len(), 1);
550        let ct = k.encrypt(b"hunter2").unwrap();
551        assert_eq!(k.decrypt(&ct).unwrap(), b"hunter2");
552    }
553}