use crate::error::{AuthError, Error, Result};
use chacha20poly1305::{
ChaCha20Poly1305, Nonce,
aead::{Aead, AeadCore, KeyInit, OsRng},
};
use secrecy::{SecretBox, SecretString};
use serde::{Deserialize, Serialize};
use std::path::Path;
use zeroize::Zeroizing;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
const KEY_SIZE: usize = 32;
const NONCE_SIZE: usize = 12;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptedToken {
pub data: Vec<u8>,
}
impl EncryptedToken {
pub fn encrypt(plaintext: &[u8], key: &[u8; KEY_SIZE]) -> Result<Self> {
let cipher = ChaCha20Poly1305::new(key.into());
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher.encrypt(&nonce, plaintext).map_err(|e| {
Error::Auth(AuthError::EncryptionFailed(format!(
"ChaCha20Poly1305 encrypt failed: {}",
e
)))
})?;
let mut data = nonce.to_vec();
data.extend_from_slice(&ciphertext);
Ok(Self { data })
}
pub fn decrypt(&self, key: &[u8; KEY_SIZE]) -> Result<SecretBox<Vec<u8>>> {
if self.data.len() < NONCE_SIZE {
return Err(Error::Auth(AuthError::DecryptionFailed(
"Encrypted data too short".to_string(),
)));
}
let (nonce_bytes, ciphertext) = self.data.split_at(NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher = ChaCha20Poly1305::new(key.into());
let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|e| {
Error::Auth(AuthError::DecryptionFailed(format!(
"ChaCha20Poly1305 decrypt failed: {}",
e
)))
})?;
Ok(SecretBox::new(Box::new(plaintext)))
}
}
pub fn generate_random_key() -> Result<Zeroizing<[u8; KEY_SIZE]>> {
use ring::rand::{SecureRandom, SystemRandom};
let rng = SystemRandom::new();
let mut key = Zeroizing::new([0u8; KEY_SIZE]);
rng.fill(&mut *key).map_err(|_| {
Error::Auth(AuthError::EncryptionFailed(
"Cryptographic RNG unavailable - system may be compromised".to_string(),
))
})?;
Ok(key)
}
#[cfg(unix)]
pub fn secure_file_write(path: &Path, data: &[u8]) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let suffix = uuid::Uuid::new_v4().simple().to_string();
let mut temp_name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
temp_name.push(format!(".{}.tmp", suffix));
let temp_path = path.with_file_name(temp_name);
let mut file = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600) .open(&temp_path)
.map_err(|e| AuthError::TokenFileError(format!("Failed to create temp file: {}", e)))?;
let written = (|| {
file.write_all(data)
.map_err(|e| AuthError::TokenFileError(format!("Failed to write data: {}", e)))?;
file.sync_all()
.map_err(|e| AuthError::TokenFileError(format!("Failed to sync file: {}", e)))?;
drop(file);
std::fs::rename(&temp_path, path)
.map_err(|e| AuthError::TokenFileError(format!("Failed to rename temp file: {}", e)))
})();
if let Err(error) = written {
let _ = std::fs::remove_file(&temp_path);
return Err(error.into());
}
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
std::fs::File::open(parent)?.sync_all()?;
verify_file_permissions(path)?;
tracing::debug!("Secure file write completed: {:?}", path);
Ok(())
}
#[cfg(windows)]
pub use crate::security_windows::secure_file_write;
#[cfg(not(any(unix, windows)))]
pub fn secure_file_write(_path: &Path, _data: &[u8]) -> Result<()> {
Err(Error::Config(
"Secure file storage is unsupported on this platform".into(),
))
}
#[cfg(unix)]
pub fn open_private_file(path: &Path) -> Result<std::fs::File> {
use rustix::fs::{Mode, OFlags};
use std::os::unix::fs::MetadataExt;
let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
let file = match rustix::fs::open(path, flags, Mode::empty()) {
Ok(fd) => std::fs::File::from(fd),
Err(rustix::io::Errno::LOOP | rustix::io::Errno::MLINK) => {
return Err(insecure(path, "is a symbolic link"));
}
Err(errno) => return Err(Error::Io(errno.into())),
};
let metadata = file.metadata()?;
if !metadata.file_type().is_file() {
return Err(insecure(path, "is not a regular file"));
}
let owner = metadata.uid();
if owner != rustix::process::geteuid().as_raw() && owner != 0 {
return Err(insecure(path, "is owned by another account"));
}
let perms = metadata.permissions().mode() & 0o777;
if perms != 0o600 {
return Err(insecure(
path,
&format!("has insecure permissions {perms:o} (expected 0600)"),
));
}
Ok(file)
}
#[cfg(windows)]
pub use crate::security_windows::open_private_file;
#[cfg(not(any(unix, windows)))]
pub fn open_private_file(_path: &Path) -> Result<std::fs::File> {
Err(Error::Config(
"Secure file storage is unsupported on this platform".into(),
))
}
pub(crate) fn insecure(path: &Path, why: &str) -> Error {
tracing::error!(?path, "secret file {why}");
Error::Auth(AuthError::TokenFileInsecure(format!(
"secret file {why}; see server logs for the path"
)))
}
pub fn verify_file_permissions(path: &Path) -> Result<()> {
open_private_file(path).map(drop)
}
pub fn read_private_file(path: &Path) -> Result<Zeroizing<Vec<u8>>> {
use std::io::Read;
let file = open_private_file(path)?;
let len = file.metadata()?.len();
let mut contents = Zeroizing::new(Vec::with_capacity(usize::try_from(len).unwrap_or(0)));
file.take(len).read_to_end(&mut contents)?;
Ok(contents)
}
pub fn read_trusted_file(path: &Path, limit: u64) -> Result<Vec<u8>> {
use std::io::Read;
#[cfg(unix)]
let file = {
use rustix::fs::{Mode, OFlags};
use std::os::unix::fs::MetadataExt;
let resolved = std::fs::canonicalize(path)?;
let trusted = |metadata: &std::fs::Metadata| {
let owner = metadata.uid();
(owner == rustix::process::geteuid().as_raw() || owner == 0)
&& metadata.permissions().mode() & 0o022 == 0
};
if let Some(parent) = resolved.parent()
&& !trusted(&std::fs::metadata(parent)?)
{
return Err(untrusted(
path,
"is in a directory another account can write",
));
}
let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
let file = std::fs::File::from(
rustix::fs::open(&resolved, flags, Mode::empty()).map_err(std::io::Error::from)?,
);
let metadata = file.metadata()?;
if !metadata.file_type().is_file() {
return Err(untrusted(path, "is not a regular file"));
}
if !trusted(&metadata) {
return Err(untrusted(
path,
"must be owned by this account or root and writable by no one else",
));
}
file
};
#[cfg(not(unix))]
let file = std::fs::File::open(path)?;
let mut contents = Vec::new();
file.take(limit + 1).read_to_end(&mut contents)?;
if contents.len() as u64 > limit {
return Err(Error::Config(format!(
"Configuration file exceeds {limit} bytes"
)));
}
Ok(contents)
}
#[cfg(unix)]
fn untrusted(path: &Path, why: &str) -> Error {
tracing::error!(?path, "configuration file {why}");
Error::Config(format!(
"A configuration file {why}; see server logs for the path"
))
}
#[cfg(unix)]
pub fn ensure_private_directory(path: &Path) -> Result<()> {
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
if !path.exists() {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)?;
}
let metadata = std::fs::symlink_metadata(path)?;
if !metadata.is_dir()
|| metadata.permissions().mode() & 0o077 != 0
|| metadata.uid() != rustix::process::geteuid().as_raw()
{
return Err(Error::Storage(
"State directory must be private (0700), owned by this account, \
and not a symlink"
.into(),
));
}
Ok(())
}
pub fn read_secret_file(path: &Path) -> Result<SecretString> {
let contents = read_private_file(path)?;
let text = std::str::from_utf8(&contents)
.map_err(|_| Error::Config("Secret file is not UTF-8".into()))?;
Ok(SecretString::from(text.trim()))
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let key = generate_random_key().unwrap();
let plaintext = b"access_token_12345678";
let encrypted = EncryptedToken::encrypt(plaintext, &key).unwrap();
let decrypted = encrypted.decrypt(&key).unwrap();
assert_eq!(decrypted.expose_secret().as_slice(), plaintext);
}
#[test]
fn test_wrong_key_fails() {
let key1 = generate_random_key().unwrap();
let key2 = generate_random_key().unwrap();
let plaintext = b"secret_data";
let encrypted = EncryptedToken::encrypt(plaintext, &key1).unwrap();
let result = encrypted.decrypt(&key2);
assert!(result.is_err(), "Decryption with wrong key should fail");
}
#[test]
fn test_tampered_data_fails() {
let key = generate_random_key().unwrap();
let plaintext = b"important_data";
let mut encrypted = EncryptedToken::encrypt(plaintext, &key).unwrap();
encrypted.data[NONCE_SIZE] ^= 0xFF;
let result = encrypted.decrypt(&key);
assert!(result.is_err(), "Decryption of tampered data should fail");
}
#[test]
#[cfg(unix)]
fn private_files_refuse_links_fifos_and_loose_modes() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("tokens.json");
secure_file_write(&real, b"secret").unwrap();
assert_eq!(&*read_private_file(&real).unwrap(), b"secret");
let link = dir.path().join("link.json");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(matches!(
read_private_file(&link),
Err(Error::Auth(AuthError::TokenFileInsecure(_)))
));
let fifo = dir.path().join("fifo");
assert!(
std::process::Command::new("mkfifo")
.args(["-m", "600"])
.arg(&fifo)
.status()
.unwrap()
.success()
);
assert!(verify_file_permissions(&fifo).is_err());
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(verify_file_permissions(&real).is_err());
let missing = read_private_file(&dir.path().join("missing")).unwrap_err();
assert!(matches!(missing, Error::Io(e) if e.kind() == std::io::ErrorKind::NotFound));
}
#[cfg(unix)]
#[test]
fn trusted_files_refuse_other_writers_and_oversize() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let policy = dir.path().join("policy.json");
std::fs::write(&policy, b"[]").unwrap();
std::fs::set_permissions(&policy, std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(read_trusted_file(&policy, 16).unwrap(), b"[]");
let link = dir.path().join("current");
std::os::unix::fs::symlink(&policy, &link).unwrap();
assert_eq!(read_trusted_file(&link, 16).unwrap(), b"[]");
assert!(read_trusted_file(&policy, 1).is_err());
std::fs::set_permissions(&policy, std::fs::Permissions::from_mode(0o666)).unwrap();
assert!(read_trusted_file(&link, 16).is_err());
std::fs::set_permissions(&policy, std::fs::Permissions::from_mode(0o644)).unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o777)).unwrap();
assert!(read_trusted_file(&policy, 16).is_err());
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[cfg(unix)]
#[test]
fn state_directories_refuse_a_symlinked_leaf() {
let dir = tempfile::tempdir().unwrap();
let elsewhere = dir.path().join("elsewhere");
ensure_private_directory(&elsewhere).unwrap();
let link = dir.path().join("state");
std::os::unix::fs::symlink(&elsewhere, &link).unwrap();
assert!(ensure_private_directory(&link).is_err());
}
#[cfg(unix)]
#[test]
fn test_secure_file_write_permissions() {
use tempfile::NamedTempFile;
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path();
let data = b"secret token data";
secure_file_write(path, data).unwrap();
let content = std::fs::read(path).unwrap();
assert_eq!(content, data);
let metadata = std::fs::symlink_metadata(path).unwrap();
assert!(metadata.file_type().is_file());
let mode = metadata.permissions().mode();
assert_eq!(mode & 0o777, 0o600, "File should have 0600 permissions");
}
}
#[cfg(windows)]
pub use crate::security_windows::ensure_private_directory;
#[cfg(not(any(unix, windows)))]
pub fn ensure_private_directory(_path: &Path) -> Result<()> {
Err(Error::Storage(
"Private state directories are unsupported on this platform".into(),
))
}