bxcore, gatewayd, loopd: the secret store moves to bxcore::secrets
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
//! Shared data types and the frame codec for Boxmaker. No policy and no I/O beyond frames.
|
||||
//! Shared data types and the frame codec for Boxmaker. No policy and no I/O beyond frames, except
|
||||
//! the secret store (`secrets`), behind the feature of that name.
|
||||
|
||||
pub mod audit;
|
||||
pub mod chain;
|
||||
@@ -12,6 +13,8 @@ pub mod hosts;
|
||||
pub mod ids;
|
||||
pub mod log;
|
||||
pub mod memory;
|
||||
#[cfg(feature = "secrets")]
|
||||
pub mod secrets;
|
||||
pub mod sha1;
|
||||
pub mod tools;
|
||||
pub mod wire;
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
//! 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.<name>]` 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<String>,
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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<SecretSource, String> {
|
||||
// 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<String>);
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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<OsString>,
|
||||
) -> Result<Loaded, SecretError> {
|
||||
// By source (M4a spec, section 4). Credential: read $CREDENTIALS_DIRECTORY/<name> (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<Zeroizing<Vec<u8>>, 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<u32, String> {
|
||||
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<u8>) -> Result<Secret, String> {
|
||||
// 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()))
|
||||
}
|
||||
Reference in New Issue
Block a user