gatewayd: secrets from a credential, the environment or a file; runbook entries

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-23 19:37:12 -07:00
parent ede25312b3
commit 608d426f95
5 changed files with 547 additions and 0 deletions
+1
View File
@@ -2,3 +2,4 @@
//! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`.
pub mod config;
pub mod secrets;
+202
View File
@@ -0,0 +1,202 @@
//! 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::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: `check_file` first, then read 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) => {
if let Err(why) = check_file(path) {
return Err(SecretError {
name: name.to_string(),
why,
});
}
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(e) => {
return Err(SecretError {
name: name.to_string(),
why: format!("cannot read {}: {}", path.display(), e),
});
}
};
let secret = value(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()
)),
})
}
}
}
/// An owner-only regular file, not a link.
fn check_file(path: &Path) -> Result<(), String> {
// In this order, each its own error: not absolute; `symlink_metadata` fails; a symbolic link;
// not a regular file; owner uid differs from the uid of /proc/self; mode & 0o077 != 0.
if !path.is_absolute() {
return Err(format!("{} is not an absolute path", path.display()));
}
let meta = std::fs::symlink_metadata(path)
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
if meta.file_type().is_symlink() {
return Err(format!("{} is a symbolic link", path.display()));
}
if !meta.file_type().is_file() {
return Err(format!("{} is not a regular file", path.display()));
}
let owner = std::fs::metadata("/proc/self")
.map_err(|e| format!("cannot read /proc/self: {}", e))?
.uid();
if meta.uid() != owner {
return Err(format!(
"{} is not owned by the user gatewayd runs as",
path.display()
));
}
let mode = meta.mode() & 0o777;
if mode & 0o077 != 0 {
return Err(format!(
"{} has mode {:03o}; only the owner may read it (0600 or 0400)",
path.display(),
mode
));
}
Ok(())
}
/// 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()))
}