//! The secret store, for every role that needs one: a secret from a systemd credential, an //! environment variable, or an owner-only file (M4a spec, section 4; M5b spec, section 5). A //! `Secret` cannot be printed. Behind the feature `secrets`: `loopd` holds no credentials and never //! enables it. use std::ffi::OsString; use std::io::Read; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use serde::Deserialize; use zeroize::Zeroizing; pub const RUNBOOK: &str = "see docs/runbook.md#secret-unavailable"; pub const RUNBOOK_FILE: &str = "see docs/runbook.md#secret-in-a-file"; /// `[secrets.]` in a role's configuration: exactly one of the three is set (checked by /// `source`). Our own format: unknown keys are errors. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct SecretSpec { #[serde(default)] pub credential: Option, #[serde(default)] pub env: Option, #[serde(default)] pub file: Option, } /// A checked `SecretSpec`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SecretSource { Credential(String), Env(String), File(PathBuf), } impl SecretSpec { /// Exactly one source, well formed. pub fn source(&self) -> Result { // Exactly one of the three set, else "needs exactly one of credential, env and file". // credential: not empty, only ASCII letters, digits, _ . -. env: not empty, only A-Z, 0-9, // _. file: an absolute path. let mut count = 0; if self.credential.is_some() { count += 1; } if self.env.is_some() { count += 1; } if self.file.is_some() { count += 1; } if count != 1 { return Err("needs exactly one of credential, env and file".to_string()); } if let Some(credential) = &self.credential { if credential.is_empty() || !credential.bytes().all(|b| { b.is_ascii_alphabetic() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-') }) { return Err(format!( "credential {:?} is not a credential name (letters, digits, _ . -)", credential )); } return Ok(SecretSource::Credential(credential.clone())); } if let Some(env) = &self.env { if env.is_empty() || !env .bytes() .all(|b| matches!(b, b'A'..=b'Z' | b'0'..=b'9' | b'_')) { return Err(format!( "env {:?} is not a variable name (A-Z, 0-9, _)", env )); } return Ok(SecretSource::Env(env.clone())); } // `count == 1` and the two above are `None`, so this is `Some`. let file = match &self.file { Some(file) => file, None => return Err("needs exactly one of credential, env and file".to_string()), }; if !file.is_absolute() { return Err(format!( "file {:?} must be an absolute path", file.display() )); } Ok(SecretSource::File(file.clone())) } } /// A secret's text. No `Display`; `Debug` shows nothing of it; wiped when dropped. pub struct Secret(Zeroizing); impl Secret { pub fn new(text: String) -> Secret { Secret(Zeroizing::new(text)) } /// The text, for the one place that must send it. pub fn expose(&self) -> &str { &self.0 } } impl std::fmt::Debug for Secret { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("Secret(…)") } } /// A loaded secret, and the warning to print for it, if any. #[derive(Debug)] pub struct Loaded { pub secret: Secret, pub warning: Option, } #[derive(Debug)] pub struct SecretError { pub name: String, pub why: String, } impl std::fmt::Display for SecretError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "secret {}: {}\n{RUNBOOK}", self.name, self.why) } } impl std::error::Error for SecretError {} /// Load secret `name` from `source` for the program `role` (`"gatewayd"`), which the sentences /// name. `env` reads an environment variable (in a role, `std::env::var_os`); tests pass their /// own. pub fn load( role: &str, name: &str, source: &SecretSource, env: &dyn Fn(&str) -> Option, ) -> Result { // By source (M4a spec, section 4). Credential: read $CREDENTIALS_DIRECTORY/ (through // `env`, not std::env); an unset variable, or a file that cannot be read, is an error. Env: the // variable, UTF-8; unset is an error. File: `read_checked` checks the path, opens and re-checks // the same file handle, reads it, and a warning is set. Every value goes through `value`. // Every error is a `SecretError` naming the secret, never the value. match source { SecretSource::Credential(cred) => { let dir = match env("CREDENTIALS_DIRECTORY") { Some(dir) => dir, None => { return Err(SecretError { name: name.to_string(), why: format!( "CREDENTIALS_DIRECTORY is not set: {role} was not started by systemd with LoadCredentialEncrypted=" ), }); } }; let path = Path::new(dir.as_os_str()).join(cred); let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) => { return Err(SecretError { name: name.to_string(), why: format!("cannot read the credential {}: {}", path.display(), e), }); } }; let secret = value(bytes).map_err(|why| SecretError { name: name.to_string(), why, })?; Ok(Loaded { secret, warning: None, }) } SecretSource::Env(var) => { let val = match env(var) { Some(val) => val, None => { return Err(SecretError { name: name.to_string(), why: format!("the environment variable {} is not set", var), }); } }; let text = match val.to_str() { Some(text) => text, None => { return Err(SecretError { name: name.to_string(), why: format!("the environment variable {} is not UTF-8", var), }); } }; let secret = value(text.as_bytes().to_vec()).map_err(|why| SecretError { name: name.to_string(), why, })?; Ok(Loaded { secret, warning: None, }) } SecretSource::File(path) => { let mut bytes = match read_checked(role, path, &|| {}) { Ok(bytes) => bytes, Err(why) => { return Err(SecretError { name: name.to_string(), why, }); } }; let secret = value(std::mem::take(&mut *bytes)).map_err(|why| SecretError { name: name.to_string(), why, })?; Ok(Loaded { secret, warning: Some(format!( "{role}: warning: secret {} is read in plaintext from {}; a systemd credential keeps it encrypted at rest ({RUNBOOK_FILE})", name, path.display() )), }) } } } /// The bytes of an owner-only regular file, read from the very file that was checked. `role` /// names the program in the owner check's sentence. `between` runs after the path is checked and /// before it is opened: `load` passes `&|| {}`; tests use it to swap the file. pub fn read_checked( role: &str, path: &Path, between: &dyn Fn(), ) -> Result>, String> { // The path is checked (absolute, not a link, a regular file), then opened, and the checks // that matter are made on the open handle: the same file as the path named, owned by this // user, no group or other bits. The race it closes is M4a review, finding 2. if !path.is_absolute() { return Err(format!("{} is not an absolute path", path.display())); } let named = std::fs::symlink_metadata(path) .map_err(|e| format!("cannot read {}: {}", path.display(), e))?; if named.file_type().is_symlink() { return Err(format!("{} is a symbolic link", path.display())); } if !named.file_type().is_file() { return Err(format!("{} is not a regular file", path.display())); } between(); let mut file = std::fs::File::open(path).map_err(|e| format!("cannot read {}: {}", path.display(), e))?; let opened = file .metadata() .map_err(|e| format!("cannot read {}: {}", path.display(), e))?; if (opened.dev(), opened.ino()) != (named.dev(), named.ino()) { return Err(format!("{} changed while it was read", path.display())); } let owner = user_id()?; if opened.uid() != owner { return Err(format!( "{} is not owned by the user {role} runs as", path.display() )); } let mode = opened.mode() & 0o777; if mode & 0o077 != 0 { return Err(format!( "{} has mode {:03o}; only the owner may read it (0600 or 0400)", path.display(), mode )); } let mut bytes = Zeroizing::new(Vec::new()); file.read_to_end(&mut bytes) .map_err(|e| format!("cannot read {}: {}", path.display(), e))?; Ok(bytes) } /// The user id this process runs as: the owner of a pipe it has just made. `std` has no call for /// it, and `/proc/self` is Linux only. fn user_id() -> Result { let (reader, _writer) = std::io::pipe().map_err(|e| format!("cannot make a pipe: {e}"))?; let pipe = std::fs::File::from(std::os::fd::OwnedFd::from(reader)); let meta = pipe .metadata() .map_err(|e| format!("cannot read a pipe: {e}"))?; Ok(meta.uid()) } /// The text without one trailing newline; not empty; UTF-8. fn value(bytes: Vec) -> Result { // Keep the bytes in `Zeroizing` until they are inside the `Secret`. let bytes = Zeroizing::new(bytes); let text = std::str::from_utf8(&bytes).map_err(|_| "the value is not UTF-8".to_string())?; let text = text.strip_suffix('\n').unwrap_or(text); if text.is_empty() { return Err("the value is empty".to_string()); } Ok(Secret::new(text.to_string())) }