M5a task 16. The work is Ornith's, from the stopped run; the design model checked it against the task file and committed it once the memoryd lint that stopped the gate was fixed (44bdcbd). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
234 lines
8.8 KiB
Rust
234 lines
8.8 KiB
Rust
//! The `SecretStore`: a secret from a systemd credential, an environment variable, or an owner-only
|
|
//! file (M4a spec, section 4; the brief after P15). A `Secret` cannot be printed.
|
|
|
|
use std::ffi::OsString;
|
|
use std::io::Read;
|
|
use std::os::unix::fs::MetadataExt;
|
|
use std::path::Path;
|
|
|
|
use zeroize::Zeroizing;
|
|
|
|
use crate::config::SecretSource;
|
|
|
|
pub const RUNBOOK: &str = "see docs/runbook.md#secret-unavailable";
|
|
pub const RUNBOOK_FILE: &str = "see docs/runbook.md#secret-in-a-file";
|
|
|
|
/// 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`. `env` reads an environment variable (in `gatewayd`,
|
|
/// `std::env::var_os`); tests pass their own.
|
|
pub fn load(
|
|
name: &str,
|
|
source: &SecretSource,
|
|
env: &dyn Fn(&str) -> Option<OsString>,
|
|
) -> Result<Loaded, SecretError> {
|
|
// By source (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 set `warning`
|
|
// to the exact text in the task. 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: "CREDENTIALS_DIRECTORY is not set: gatewayd was not started by systemd with LoadCredentialEncrypted=".to_string(),
|
|
}),
|
|
};
|
|
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(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!(
|
|
"gatewayd: 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. `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(path: &Path, between: &dyn Fn()) -> Result<Zeroizing<Vec<u8>>, String> {
|
|
// 1. `path.is_absolute()`, else "<path> is not an absolute path".
|
|
// 2. `let named = std::fs::symlink_metadata(path)`, an Err(e) is "cannot read <path>: <e>";
|
|
// `named.file_type().is_symlink()` is "<path> is a symbolic link";
|
|
// `!named.file_type().is_file()` is "<path> is not a regular file".
|
|
// 3. `between();`
|
|
// 4. `let mut file = std::fs::File::open(path)`, an Err(e) is "cannot read <path>: <e>";
|
|
// `let opened = file.metadata()`, the same error text.
|
|
// 5. `if (opened.dev(), opened.ino()) != (named.dev(), named.ino())`:
|
|
// "<path> changed while it was read".
|
|
// 6. The owner and mode checks from the original file check, word for word, on `opened` (not
|
|
// on `named`): the uid of `user_id()`; "<path> is not owned by the user gatewayd runs as";
|
|
// "<path> has mode <mode:03o>; only the owner may read it (0600 or 0400)".
|
|
// 7. `let mut bytes = Zeroizing::new(Vec::new());` then `file.read_to_end(&mut bytes)`, an
|
|
// Err(e) is "cannot read <path>: <e>". Ok(bytes).
|
|
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 gatewayd 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> {
|
|
// UTF-8 (else an error), one trailing newline removed, not empty. 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()))
|
|
}
|