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:
@@ -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;
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! The secret store (M4a spec, section 4). The environment is passed in as a function, so no test
|
||||
//! changes the process's environment. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gatewayd::config::SecretSource;
|
||||
use gatewayd::secrets::{RUNBOOK, RUNBOOK_FILE, load};
|
||||
use tmp::TempDir;
|
||||
|
||||
const TOKEN: &str = "s3cret-t0ken-value";
|
||||
|
||||
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> + use<> {
|
||||
let map: HashMap<String, OsString> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), OsString::from(v)))
|
||||
.collect();
|
||||
move |k| map.get(k).cloned()
|
||||
}
|
||||
|
||||
fn owner_file(dir: &TempDir, name: &str, text: &str, mode: u32) -> PathBuf {
|
||||
let path = dir.write(name, text);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn refused(source: &SecretSource, env: &dyn Fn(&str) -> Option<OsString>, word: &str) {
|
||||
let e = load("mattermost_token", source, env).expect_err(word);
|
||||
let text = e.to_string();
|
||||
assert!(text.contains(word), "{word}: {text}");
|
||||
assert!(text.starts_with("secret mattermost_token: "), "{text}");
|
||||
assert!(text.ends_with(RUNBOOK), "{text}");
|
||||
assert!(
|
||||
!text.contains(TOKEN),
|
||||
"a refusal never shows the value: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_systemd_credential() {
|
||||
let dir = TempDir::new("cred");
|
||||
dir.write("creds/mattermost-token", &format!("{TOKEN}\n"));
|
||||
let creds = dir.path().join("creds");
|
||||
let env = env_of(&[("CREDENTIALS_DIRECTORY", creds.to_str().unwrap())]);
|
||||
let got = load(
|
||||
"mattermost_token",
|
||||
&SecretSource::Credential("mattermost-token".into()),
|
||||
&env,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
got.secret.expose(),
|
||||
TOKEN,
|
||||
"one trailing newline is removed"
|
||||
);
|
||||
assert_eq!(got.warning, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credential_outside_systemd_or_missing_is_refused() {
|
||||
let src = SecretSource::Credential("mattermost-token".into());
|
||||
refused(&src, &env_of(&[]), "CREDENTIALS_DIRECTORY is not set");
|
||||
let dir = TempDir::new("cred-missing");
|
||||
refused(
|
||||
&src,
|
||||
&env_of(&[("CREDENTIALS_DIRECTORY", dir.path().to_str().unwrap())]),
|
||||
"cannot read the credential",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_environment_variable() {
|
||||
let got = load(
|
||||
"mattermost_token",
|
||||
&SecretSource::Env("MM".into()),
|
||||
&env_of(&[("MM", TOKEN)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got.secret.expose(), TOKEN);
|
||||
assert_eq!(got.warning, None, "only a file warns");
|
||||
refused(&SecretSource::Env("MM".into()), &env_of(&[]), "is not set");
|
||||
refused(
|
||||
&SecretSource::Env("MM".into()),
|
||||
&env_of(&[("MM", "")]),
|
||||
"empty",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_owner_only_file_is_read_with_a_warning() {
|
||||
let dir = TempDir::new("file");
|
||||
for mode in [0o600, 0o400] {
|
||||
let path = owner_file(
|
||||
&dir,
|
||||
&format!("token-{mode:o}"),
|
||||
&format!("{TOKEN}\n"),
|
||||
mode,
|
||||
);
|
||||
let got = load(
|
||||
"mattermost_token",
|
||||
&SecretSource::File(path.clone()),
|
||||
&env_of(&[]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got.secret.expose(), TOKEN);
|
||||
let warning = got.warning.expect("a file secret warns");
|
||||
assert!(
|
||||
warning.starts_with(
|
||||
"gatewayd: warning: secret mattermost_token is read in plaintext from "
|
||||
),
|
||||
"{warning}"
|
||||
);
|
||||
assert!(warning.contains(path.to_str().unwrap()), "{warning}");
|
||||
assert!(warning.contains(RUNBOOK_FILE), "{warning}");
|
||||
assert!(!warning.contains(TOKEN));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_anyone_else_can_read_or_that_is_not_a_plain_file_is_refused() {
|
||||
let dir = TempDir::new("file-bad");
|
||||
for (mode, _) in [
|
||||
(0o640, "group"),
|
||||
(0o604, "other"),
|
||||
(0o644, "both"),
|
||||
(0o660, "group write"),
|
||||
] {
|
||||
let path = owner_file(&dir, &format!("t-{mode:o}"), TOKEN, mode);
|
||||
refused(
|
||||
&SecretSource::File(path),
|
||||
&env_of(&[]),
|
||||
"only the owner may read it",
|
||||
);
|
||||
}
|
||||
let target = owner_file(&dir, "real", TOKEN, 0o600);
|
||||
let link = dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
refused(&SecretSource::File(link), &env_of(&[]), "symbolic link");
|
||||
std::fs::create_dir(dir.path().join("adir")).unwrap();
|
||||
refused(
|
||||
&SecretSource::File(dir.path().join("adir")),
|
||||
&env_of(&[]),
|
||||
"not a regular file",
|
||||
);
|
||||
refused(
|
||||
&SecretSource::File(dir.path().join("missing")),
|
||||
&env_of(&[]),
|
||||
"cannot read",
|
||||
);
|
||||
refused(
|
||||
&SecretSource::File(PathBuf::from("relative/token")),
|
||||
&env_of(&[]),
|
||||
"absolute",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_non_utf8_values_are_refused() {
|
||||
let dir = TempDir::new("value");
|
||||
refused(
|
||||
&SecretSource::File(owner_file(&dir, "empty", "", 0o600)),
|
||||
&env_of(&[]),
|
||||
"empty",
|
||||
);
|
||||
refused(
|
||||
&SecretSource::File(owner_file(&dir, "nl", "\n", 0o600)),
|
||||
&env_of(&[]),
|
||||
"empty",
|
||||
);
|
||||
let path = dir.path().join("bin");
|
||||
std::fs::write(&path, [0xff, 0xfe]).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
refused(&SecretSource::File(path), &env_of(&[]), "not UTF-8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_trailing_newline_is_removed_and_spaces_stay() {
|
||||
let dir = TempDir::new("trim");
|
||||
let path = owner_file(&dir, "t", " a b \n\n", 0o600);
|
||||
let got = load("x", &SecretSource::File(path), &env_of(&[])).unwrap();
|
||||
assert_eq!(got.secret.expose(), " a b \n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_prints_nothing_of_itself() {
|
||||
let got = load(
|
||||
"mattermost_token",
|
||||
&SecretSource::Env("MM".into()),
|
||||
&env_of(&[("MM", TOKEN)]),
|
||||
)
|
||||
.unwrap();
|
||||
let shown = format!("{:?} {:?}", got.secret, got);
|
||||
assert!(!shown.contains(TOKEN), "{shown}");
|
||||
assert!(shown.contains("Secret(…)"), "{shown}");
|
||||
}
|
||||
Reference in New Issue
Block a user