1use 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
20pub const CREDENTIAL_NAME: &str = "isb-age-key";
22
23#[derive(Clone)]
26pub enum Recipient {
27 X25519(age::x25519::Recipient),
28 Ssh(age::ssh::Recipient),
29}
30
31impl Recipient {
32 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 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
84fn 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
94pub 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
106pub 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
115pub 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#[derive(Clone, Default)]
127pub struct KeySources {
128 pub key: Option<String>,
130 pub credentials_dir: Option<PathBuf>,
132 pub key_file: Option<PathBuf>,
134 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#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum KeyOrigin {
164 Env,
165 Credential(PathBuf),
166 KeyFile(PathBuf),
167 DefaultFile(PathBuf),
168 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
184pub struct LoadedKey {
186 pub identity: age::x25519::Identity,
187 pub origin: KeyOrigin,
188 pub notes: Vec<String>,
189}
190
191pub fn load_identity(src: &KeySources) -> Result<LoadedKey> {
193 lookup_identity(src, true)
194}
195
196pub 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 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 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
259pub 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct SecretsConfig {
301 #[serde(default)]
304 pub recipients: Vec<String>,
305}
306
307impl SecretsConfig {
308 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 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
342pub 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 pub fn public_key(&self) -> String {
365 self.identity.to_public().to_string()
366 }
367
368 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 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 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 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 src.key_file = Some(dir.path().join("missing.txt"));
474 assert!(load_identity(&src).is_err());
475 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 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 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 assert!(generate_identity_file(&src.default_file).is_err());
536 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}