write secret and key files atomically with their final mode
Each of these wrote its file in place (or set the mode after the write), so a write cut short by a kill or a full disk left a partial file behind, and some secrets existed for a moment at umask mode: - swarm-authelia/swarm-grafana/hive-forge/swarm-bao generators: a partial file passed the `-s`/`-e` guard and was kept forever. They now capture the value first and land it with `atomic_write_secret`. - swarm-controller webhook secret: a truncated file failed the 64-hex check and the secret was regenerated, out of step with the forge hooks. It is now written 0600 to a sibling temp, fsynced, renamed. - hive-c0re htpasswd: the temp is now fsynced and set 0644 before the rename, like the sibling agents.conf writer, instead of relying on c0re's umask. - hivectl wg init: the private key is created 0400 in a temp and renamed, instead of written at umask mode and chmod'ed after. Closes #5068
This commit is contained in:
parent
1726ad3bad
commit
01b8387ab8
7 changed files with 156 additions and 16 deletions
|
|
@ -120,12 +120,39 @@ fn load_or_generate_at(path: &std::path::Path) -> Result<String> {
|
|||
let secret = generate_hex_secret()?;
|
||||
std::fs::create_dir_all(path.parent().unwrap_or(path))
|
||||
.with_context(|| format!("create dir for {}", path.display()))?;
|
||||
std::fs::write(path, format!("{secret}\n"))
|
||||
write_secret_0600(path, format!("{secret}\n").as_bytes())
|
||||
.with_context(|| format!("write webhook secret to {}", path.display()))?;
|
||||
tracing::info!(path = %path.display(), "webhook secret generated and persisted");
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
/// Write `contents` to a sibling temp file opened `0600` and rename it over
|
||||
/// `path`, so the secret is never readable by anyone else and a crash never
|
||||
/// leaves half of it at `path`. Errors carry paths, never `contents`.
|
||||
fn write_secret_0600(path: &std::path::Path, contents: &[u8]) -> Result<()> {
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
|
||||
let mut tmp_name = path
|
||||
.file_name()
|
||||
.with_context(|| format!("{} has no file name", path.display()))?
|
||||
.to_owned();
|
||||
tmp_name.push(".tmp");
|
||||
let tmp = path.with_file_name(tmp_name);
|
||||
// A temp file left by a crashed earlier write would fail `create_new`.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.with_context(|| format!("create {}", tmp.display()))?;
|
||||
f.write_all(contents)
|
||||
.and_then(|()| f.sync_all())
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, path).with_context(|| format!("rename onto {}", path.display()))
|
||||
}
|
||||
|
||||
/// Read 32 random bytes from `/dev/urandom` and hex-encode them.
|
||||
///
|
||||
/// `pub(super)` rather than private: `forge::Client::ensure_agent_user`
|
||||
|
|
@ -600,6 +627,30 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// A generated secret lands at `0600` whatever the umask, holds exactly
|
||||
/// what was returned, and leaves no temp file beside it.
|
||||
#[test]
|
||||
fn a_generated_secret_is_0600_with_no_temp_left() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("swarm-hook-mode-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
let path = dir.join("webhook-secret");
|
||||
|
||||
let secret = load_or_generate_at(&path).expect("generates");
|
||||
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
|
||||
let content = std::fs::read_to_string(&path).expect("read");
|
||||
let entries: Vec<_> = std::fs::read_dir(&dir)
|
||||
.expect("readdir")
|
||||
.map(|e| e.expect("entry").file_name())
|
||||
.collect();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
assert_eq!(mode, 0o600);
|
||||
assert_eq!(content, format!("{secret}\n"));
|
||||
assert_eq!(entries, [std::ffi::OsString::from("webhook-secret")]);
|
||||
}
|
||||
|
||||
/// A path that exists but cannot be read is an error, and nothing is
|
||||
/// written over it. A directory stands in for the unreadable file because
|
||||
/// its read fails the same way for root and non-root test runners.
|
||||
|
|
|
|||
Loading…
Reference in a new issue