225 lines
8.3 KiB
Rust
225 lines
8.3 KiB
Rust
//! Persistent signing material must never fall back to an ephemeral key.
|
|
use std::fs::{self, File, OpenOptions};
|
|
use std::io::{self, Read, Write};
|
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
pub(super) fn read_existing(path: &Path) -> io::Result<Vec<u8>> {
|
|
let file = OpenOptions::new()
|
|
.read(true)
|
|
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
|
.open(path)?;
|
|
let metadata = file.metadata()?;
|
|
if !metadata.is_file() || metadata.len() != 32 {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"Session key must be a regular 32-byte file; existing data was preserved",
|
|
));
|
|
}
|
|
// Tighten legacy modes on the opened inode. Permission failures are errors,
|
|
// never permission to replace a valid key or start with a temporary one.
|
|
if metadata.permissions().mode() & 0o777 != 0o600 {
|
|
file.set_permissions(fs::Permissions::from_mode(0o600))?;
|
|
file.sync_all()?;
|
|
}
|
|
let mut bytes = Vec::with_capacity(32);
|
|
file.take(33).read_to_end(&mut bytes)?;
|
|
if bytes.len() != 32 {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"Session key changed while reading",
|
|
));
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
|
|
struct TemporaryKey(PathBuf);
|
|
impl Drop for TemporaryKey {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_file(&self.0);
|
|
}
|
|
}
|
|
|
|
pub(super) fn load_or_create(path: &Path) -> io::Result<Vec<u8>> {
|
|
match read_existing(path) {
|
|
Ok(key) => return Ok(key),
|
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
|
Err(error) => return Err(error),
|
|
}
|
|
let parent = path.parent().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"Session key has no parent directory",
|
|
)
|
|
})?;
|
|
fs::create_dir_all(parent)?;
|
|
let mut key = [0u8; 32];
|
|
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut key)
|
|
.map_err(|_| io::Error::other("Session key entropy unavailable"))?;
|
|
// Publish only a fully written, synced private inode. A hard link provides
|
|
// no-replace semantics: concurrent creators all read the one winning key.
|
|
// The temporary filename is independent of the secret.
|
|
let temporary_path = parent.join(format!(".session-key-{}", uuid::Uuid::new_v4()));
|
|
let mut file = OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(0o600)
|
|
.open(&temporary_path)?;
|
|
let temporary = TemporaryKey(temporary_path);
|
|
file.write_all(&key)?;
|
|
file.sync_all()?;
|
|
match fs::hard_link(&temporary.0, path) {
|
|
Ok(()) => {}
|
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
|
|
Err(error) => return Err(error),
|
|
}
|
|
drop(temporary);
|
|
File::open(parent)?.sync_all()?;
|
|
read_existing(path)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::os::unix::fs::symlink;
|
|
|
|
#[test]
|
|
fn durable_private_key_survives_reload_without_rotation() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("key");
|
|
let key = load_or_create(&path).unwrap();
|
|
assert_eq!(key.len(), 32);
|
|
assert_eq!(key, load_or_create(&path).unwrap());
|
|
assert_eq!(
|
|
fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
|
0o600
|
|
);
|
|
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_existing_key_is_preserved_and_rejected() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("key");
|
|
fs::write(&path, b"partial key").unwrap();
|
|
assert_eq!(
|
|
load_or_create(&path).unwrap_err().kind(),
|
|
io::ErrorKind::InvalidData
|
|
);
|
|
assert_eq!(fs::read(path).unwrap(), b"partial key");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_mode_is_tightened_without_changing_key() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("key");
|
|
fs::write(&path, [42; 32]).unwrap();
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
|
|
assert_eq!(load_or_create(&path).unwrap(), vec![42; 32]);
|
|
assert_eq!(
|
|
fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
|
0o600
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn symlinks_and_non_files_are_rejected_without_replacement() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let target = dir.path().join("target");
|
|
fs::write(&target, [42; 32]).unwrap();
|
|
let link = dir.path().join("link");
|
|
symlink(&target, &link).unwrap();
|
|
assert!(load_or_create(&link).is_err());
|
|
assert!(load_or_create(dir.path()).is_err());
|
|
assert_eq!(fs::read(target).unwrap(), vec![42; 32]);
|
|
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
|
|
}
|
|
|
|
#[test]
|
|
fn failed_storage_never_returns_an_ephemeral_key() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let parent = dir.path().join("not-a-directory");
|
|
fs::write(&parent, b"preserve").unwrap();
|
|
assert!(load_or_create(&parent.join("key")).is_err());
|
|
assert_eq!(fs::read(parent).unwrap(), b"preserve");
|
|
}
|
|
|
|
#[test]
|
|
fn unreadable_existing_key_is_not_replaced() {
|
|
use std::os::fd::AsRawFd;
|
|
use std::os::unix::process::CommandExt;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
|
let path = dir.path().join("key");
|
|
fs::write(&path, [42; 32]).unwrap();
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
|
|
if unsafe { libc::geteuid() } == 0 {
|
|
// The isolated runner is root. Probe as an unprivileged child so
|
|
// DAC_OVERRIDE cannot hide the exact production failure.
|
|
// Execute an already-open inode: the test checkout may live under
|
|
// a private home directory which the probe must not traverse.
|
|
let executable = File::open(std::env::current_exe().unwrap()).unwrap();
|
|
let status =
|
|
std::process::Command::new(format!("/proc/self/fd/{}", executable.as_raw_fd()))
|
|
.args([
|
|
"--ignored",
|
|
"--exact",
|
|
"session::secret_file::tests::permission_denied_child_probe",
|
|
])
|
|
.env("ARCHY_SESSION_KEY_PERMISSION_PROBE", &path)
|
|
.uid(65534)
|
|
.gid(65534)
|
|
.status()
|
|
.unwrap();
|
|
assert!(status.success());
|
|
} else {
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
|
|
assert_eq!(
|
|
load_or_create(&path).unwrap_err().kind(),
|
|
io::ErrorKind::PermissionDenied
|
|
);
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
|
|
}
|
|
assert_eq!(fs::read(path).unwrap(), vec![42; 32]);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "Executed by unreadable_existing_key_is_not_replaced as an unprivileged child"]
|
|
fn permission_denied_child_probe() {
|
|
let path = PathBuf::from(
|
|
std::env::var_os("ARCHY_SESSION_KEY_PERMISSION_PROBE")
|
|
.expect("parent provides private fixture path"),
|
|
);
|
|
assert_eq!(
|
|
load_or_create(&path).unwrap_err().kind(),
|
|
io::ErrorKind::PermissionDenied
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn concurrent_first_boot_creators_agree_on_one_key() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("key");
|
|
let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
|
|
let workers: Vec<_> = (0..8)
|
|
.map(|_| {
|
|
let path = path.clone();
|
|
let barrier = barrier.clone();
|
|
std::thread::spawn(move || {
|
|
barrier.wait();
|
|
load_or_create(&path).unwrap()
|
|
})
|
|
})
|
|
.collect();
|
|
let keys: Vec<_> = workers
|
|
.into_iter()
|
|
.map(|worker| worker.join().unwrap())
|
|
.collect();
|
|
for key in &keys {
|
|
assert_eq!(key, &keys[0]);
|
|
}
|
|
assert_eq!(fs::read(path).unwrap(), keys[0]);
|
|
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
|
|
}
|
|
}
|