use std::path::Path;
use aes_gcm::KeyInit;
use chacha20poly1305::{XChaCha20Poly1305, XNonce, aead::OsRng};
use zeroize::Zeroize;
use super::*;
const NONCE_SIZE: usize = 24;
pub(super) const CIPHER_SIZE: usize = 32;
pub(crate) const DELEGATE_CIPHER_FILENAME: &str = "delegate_cipher";
pub(crate) const LEGACY_DEFAULT_NONCE: [u8; 24] = [
57, 18, 79, 116, 63, 134, 93, 39, 208, 161, 156, 229, 222, 247, 111, 79, 210, 126, 127, 55,
224, 150, 139, 80,
];
pub(crate) const LEGACY_DEFAULT_CIPHER: [u8; 32] = [
0, 24, 22, 150, 112, 207, 24, 65, 182, 161, 169, 227, 66, 182, 237, 215, 206, 164, 58, 161, 64,
108, 157, 195, 0, 0, 0, 0, 0, 0, 0, 0,
];
fn generate_cipher_key() -> [u8; CIPHER_SIZE] {
let key = XChaCha20Poly1305::generate_key(&mut OsRng);
let mut out = [0u8; CIPHER_SIZE];
out.copy_from_slice(key.as_slice());
out
}
fn save_cipher_new(path: &Path, key: &[u8; CIPHER_SIZE]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(path)?;
file.write_all(key)?;
file.sync_all()?;
Ok(())
}
impl ConfigArgs {
pub(super) fn read_secrets(
path_to_key: Option<PathBuf>,
path_to_nonce: Option<PathBuf>,
path_to_cipher: Option<PathBuf>,
) -> std::io::Result<Secrets> {
let transport_keypair = if let Some(ref path_to_key) = path_to_key {
read_transport_keypair(path_to_key)?
} else {
TransportKeypair::new()
};
let nonce = if let Some(ref path_to_nonce) = path_to_nonce {
tracing::warn!(
"`nonce` config is deprecated since per-write nonces landed; the file at \
{path:?} is used only as a legacy-decrypt nonce for pre-existing secrets.",
path = path_to_nonce
);
read_nonce(path_to_nonce)?
} else {
LEGACY_DEFAULT_NONCE
};
let cipher = if let Some(ref path_to_cipher) = path_to_cipher {
read_cipher(path_to_cipher)?
} else {
LEGACY_DEFAULT_CIPHER
};
Ok(Secrets {
transport_keypair,
transport_keypair_path: path_to_key,
nonce,
nonce_path: path_to_nonce,
cipher,
cipher_path: path_to_cipher,
})
}
}
#[derive(Debug, Default, Clone, clap::Parser, serde::Serialize, serde::Deserialize)]
pub struct SecretArgs {
#[clap(long, value_parser, default_value=None, env = "TRANSPORT_KEYPAIR")]
pub transport_keypair: Option<PathBuf>,
#[clap(long, value_parser, default_value=None, env = "NONCE")]
pub nonce: Option<PathBuf>,
#[clap(long, value_parser, default_value=None, env = "CIPHER")]
pub cipher: Option<PathBuf>,
}
impl SecretArgs {
pub(super) fn build(self, secrets_dir: Option<&Path>) -> std::io::Result<Secrets> {
let (transport_keypair_path, transport_keypair) =
if let Some(ref explicit_path) = self.transport_keypair {
let keypair = read_transport_keypair(explicit_path)?;
(self.transport_keypair, keypair)
} else if let Some(dir) = secrets_dir {
let default_path = dir.join("transport_keypair");
if default_path.exists() {
tracing::info!(
path = %default_path.display(),
"Loading persisted transport keypair"
);
let keypair = read_transport_keypair(&default_path)?;
(Some(default_path), keypair)
} else {
std::fs::create_dir_all(dir)?;
let keypair = TransportKeypair::new();
keypair.save(&default_path)?;
tracing::info!(
path = %default_path.display(),
"Generated and saved new transport keypair"
);
(Some(default_path), keypair)
}
} else {
(None, TransportKeypair::new())
};
let nonce = self.nonce.as_ref().map(read_nonce).transpose()?;
let (nonce_path, nonce) = if let Some(nonce) = nonce {
tracing::warn!(
"`--nonce` / NONCE config is deprecated since per-write nonces landed; the \
supplied value is used only as a legacy-decrypt nonce for pre-existing secrets."
);
(self.nonce, nonce)
} else {
(None, LEGACY_DEFAULT_NONCE)
};
let explicit_cipher = self.cipher.as_ref().map(read_cipher).transpose()?;
let (cipher_path, cipher) = if let Some(cipher) = explicit_cipher {
(self.cipher, cipher)
} else if let Some(dir) = secrets_dir {
let default_path = dir.join(DELEGATE_CIPHER_FILENAME);
if default_path.exists() {
tracing::info!(
path = %default_path.display(),
"Loading persisted delegate cipher"
);
let cipher = read_cipher(&default_path)?;
(Some(default_path), cipher)
} else {
std::fs::create_dir_all(dir)?;
let cipher = generate_cipher_key();
match save_cipher_new(&default_path, &cipher) {
Ok(()) => {
tracing::info!(
path = %default_path.display(),
"Generated and saved new delegate cipher"
);
(Some(default_path), cipher)
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
tracing::info!(
path = %default_path.display(),
"Cipher file appeared concurrently; loading the winning copy"
);
let cipher = read_cipher(&default_path)?;
(Some(default_path), cipher)
}
Err(e) => return Err(e),
}
}
} else {
(None, generate_cipher_key())
};
Ok(Secrets {
transport_keypair,
transport_keypair_path,
nonce,
nonce_path,
cipher,
cipher_path,
})
}
pub(super) fn merge(&mut self, mut other: Secrets) {
if self.transport_keypair.is_none() {
self.transport_keypair = std::mem::take(&mut other.transport_keypair_path);
}
if self.nonce.is_none() {
self.nonce = std::mem::take(&mut other.nonce_path);
}
if self.cipher.is_none() {
self.cipher = std::mem::take(&mut other.cipher_path);
}
}
}
#[derive(Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Secrets {
#[serde(skip)]
pub transport_keypair: TransportKeypair,
#[serde(rename = "transport_keypair", skip_serializing_if = "Option::is_none")]
pub transport_keypair_path: Option<PathBuf>,
#[serde(skip)]
pub nonce: [u8; 24],
#[serde(rename = "nonce", skip_serializing_if = "Option::is_none")]
pub nonce_path: Option<PathBuf>,
#[serde(skip)]
pub cipher: [u8; 32],
#[serde(rename = "cipher", skip_serializing_if = "Option::is_none")]
pub cipher_path: Option<PathBuf>,
}
impl std::fmt::Debug for Secrets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secrets")
.field("transport_keypair", &"<redacted>")
.field("transport_keypair_path", &self.transport_keypair_path)
.field("nonce", &"<redacted 24 bytes>")
.field("nonce_path", &self.nonce_path)
.field("cipher", &"<redacted 32 bytes>")
.field("cipher_path", &self.cipher_path)
.finish()
}
}
impl Drop for Secrets {
fn drop(&mut self) {
self.cipher.zeroize();
self.nonce.zeroize();
}
}
#[cfg(test)]
impl Default for Secrets {
fn default() -> Self {
let transport_keypair = TransportKeypair::new();
let cipher = generate_cipher_key();
let nonce = LEGACY_DEFAULT_NONCE;
Secrets {
transport_keypair,
transport_keypair_path: None,
nonce,
nonce_path: None,
cipher,
cipher_path: None,
}
}
}
impl Secrets {
#[inline]
pub fn nonce(&self) -> XNonce {
self.nonce.into()
}
#[inline]
pub fn cipher(&self) -> XChaCha20Poly1305 {
XChaCha20Poly1305::new((&self.cipher).into())
}
#[inline]
pub fn transport_keypair(&self) -> &TransportKeypair {
&self.transport_keypair
}
}
fn read_nonce(path_to_nonce: impl AsRef<Path>) -> std::io::Result<[u8; NONCE_SIZE]> {
let path_to_nonce = path_to_nonce.as_ref();
let mut nonce_file = File::open(path_to_nonce).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("Failed to open key file {}: {e}", path_to_nonce.display()),
)
})?;
let mut buf = [0u8; NONCE_SIZE];
nonce_file.read_exact(&mut buf).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("Failed to read key file {}: {e}", path_to_nonce.display()),
)
})?;
Ok::<_, std::io::Error>(buf)
}
fn read_cipher(path_to_cipher: impl AsRef<Path>) -> std::io::Result<[u8; CIPHER_SIZE]> {
let path_to_cipher = path_to_cipher.as_ref();
let mut cipher_file = File::open(path_to_cipher).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("Failed to open key file {}: {e}", path_to_cipher.display()),
)
})?;
let mut buf = [0u8; CIPHER_SIZE];
cipher_file.read_exact(&mut buf).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("Failed to read key file {}: {e}", path_to_cipher.display()),
)
})?;
Ok::<_, std::io::Error>(buf)
}
fn read_transport_keypair(path_to_key: impl AsRef<Path>) -> std::io::Result<TransportKeypair> {
let path = path_to_key.as_ref();
match TransportKeypair::load(path) {
Ok(keypair) => Ok(keypair),
Err(e) => {
if let Ok(content) = std::fs::read_to_string(path) {
if content.trim().starts_with("-----BEGIN") {
tracing::warn!(
path = %path.display(),
"Found RSA PEM key (legacy format). Generating new X25519 keypair. \
The old key file will be overwritten."
);
let keypair = TransportKeypair::new();
keypair.save(path)?;
if let Some(parent) = path.parent() {
let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
let public_filename = filename.replace("private", "public");
let public_key_path = parent.join(&public_filename);
if public_key_path.exists() {
if let Err(e) = keypair.public().save(&public_key_path) {
tracing::warn!(
path = %public_key_path.display(),
error = %e,
"Failed to update public key file"
);
} else {
tracing::info!(
path = %public_key_path.display(),
"Updated public key file to X25519 format"
);
}
}
}
return Ok(keypair);
}
}
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_from_different_files() {
let transport_keypair = TransportKeypair::new();
let nonce = [0u8; NONCE_SIZE];
let cipher = [0u8; CIPHER_SIZE];
let transport_keypair_file = tempfile::NamedTempFile::new().unwrap();
let mut nonce_file = tempfile::NamedTempFile::new().unwrap();
let mut cipher_file = tempfile::NamedTempFile::new().unwrap();
transport_keypair
.save(transport_keypair_file.path())
.unwrap();
nonce_file.write_all(&nonce).unwrap();
cipher_file.write_all(&cipher).unwrap();
let secrets = Secrets {
transport_keypair,
transport_keypair_path: Some(transport_keypair_file.path().to_path_buf()),
nonce,
nonce_path: Some(nonce_file.path().to_path_buf()),
cipher,
cipher_path: Some(cipher_file.path().to_path_buf()),
};
let secret_args = SecretArgs {
transport_keypair: Some(transport_keypair_file.path().to_path_buf()),
nonce: Some(nonce_file.path().to_path_buf()),
cipher: Some(cipher_file.path().to_path_buf()),
};
let loaded_secrets = secret_args.build(None).unwrap();
assert_eq!(secrets, loaded_secrets);
}
#[test]
fn test_load_default_generates_random_cipher() {
let secret_args = SecretArgs::default();
let loaded_secrets = secret_args.build(None).unwrap();
assert_ne!(
LEGACY_DEFAULT_CIPHER, loaded_secrets.cipher,
"build(None) must NOT seed the historical default cipher"
);
assert_ne!(
[0u8; CIPHER_SIZE], loaded_secrets.cipher,
"build(None) must produce a non-zero cipher"
);
let another = SecretArgs::default().build(None).unwrap();
assert_ne!(
loaded_secrets.cipher, another.cipher,
"two ephemeral builds must produce distinct random ciphers"
);
assert_eq!(LEGACY_DEFAULT_NONCE, loaded_secrets.nonce);
}
#[test]
fn test_cipher_auto_persist_and_reload() {
let tmp_dir = tempfile::tempdir().unwrap();
let secrets_dir = tmp_dir.path();
let args1 = SecretArgs::default();
let secrets1 = args1.build(Some(secrets_dir)).unwrap();
let cipher_path = secrets_dir.join(DELEGATE_CIPHER_FILENAME);
assert!(cipher_path.exists(), "cipher file should be created");
assert_eq!(
secrets1.cipher_path.as_deref(),
Some(cipher_path.as_path()),
"cipher_path should point at the persisted file"
);
assert_ne!(
LEGACY_DEFAULT_CIPHER, secrets1.cipher,
"auto-generated cipher must NOT equal the historical default"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&cipher_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "cipher file must be 0o600, got {mode:o}");
}
let args2 = SecretArgs::default();
let secrets2 = args2.build(Some(secrets_dir)).unwrap();
assert_eq!(
secrets1.cipher, secrets2.cipher,
"second build must reload the persisted cipher"
);
}
#[test]
fn test_missing_cipher_path_is_hard_error() {
let tmp_dir = tempfile::tempdir().unwrap();
let missing = tmp_dir.path().join("does-not-exist");
let args = SecretArgs {
cipher: Some(missing),
..Default::default()
};
let err = args
.build(None)
.expect_err("missing cipher path must error");
assert_eq!(
err.kind(),
std::io::ErrorKind::NotFound,
"expected NotFound, got {err:?}"
);
}
#[test]
fn test_keypair_auto_persist_and_reload() {
let tmp_dir = tempfile::tempdir().unwrap();
let secrets_dir = tmp_dir.path();
let args1 = SecretArgs::default();
let secrets1 = args1.build(Some(secrets_dir)).unwrap();
let keypair_path = secrets_dir.join("transport_keypair");
assert!(keypair_path.exists(), "keypair file should be created");
assert_eq!(
secrets1.transport_keypair_path.as_deref(),
Some(keypair_path.as_path())
);
let args2 = SecretArgs::default();
let secrets2 = args2.build(Some(secrets_dir)).unwrap();
assert_eq!(
secrets1.transport_keypair.public(),
secrets2.transport_keypair.public(),
"reloaded keypair should have the same public key"
);
assert_eq!(
secrets2.transport_keypair_path.as_deref(),
Some(keypair_path.as_path()),
"reloaded keypair should preserve the path"
);
}
#[cfg(unix)]
#[test]
fn test_transport_keypair_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let tmp_dir = tempfile::tempdir().unwrap();
let secrets_dir = tmp_dir.path();
let args = SecretArgs::default();
let secrets = args.build(Some(secrets_dir)).unwrap();
let keypair_path = secrets
.transport_keypair_path
.as_deref()
.expect("auto-persisted keypair has a path");
let mode = std::fs::metadata(keypair_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o600,
"auto-persisted transport_keypair must be 0o600, got {mode:o}"
);
let direct_path = secrets_dir.join("direct_keypair");
TransportKeypair::new().save(&direct_path).unwrap();
let direct_mode = std::fs::metadata(&direct_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
direct_mode, 0o600,
"direct TransportKeypair::save must produce 0o600, got {direct_mode:o}"
);
}
#[test]
fn test_explicit_keypair_overrides_secrets_dir() {
let tmp_dir = tempfile::tempdir().unwrap();
let secrets_dir = tmp_dir.path();
let args_seed = SecretArgs::default();
let seeded = args_seed.build(Some(secrets_dir)).unwrap();
let explicit_file = tempfile::NamedTempFile::new().unwrap();
let different_keypair = TransportKeypair::new();
different_keypair.save(explicit_file.path()).unwrap();
let args = SecretArgs {
transport_keypair: Some(explicit_file.path().to_path_buf()),
..Default::default()
};
let loaded = args.build(Some(secrets_dir)).unwrap();
assert_eq!(
loaded.transport_keypair.public(),
different_keypair.public()
);
assert_ne!(
loaded.transport_keypair.public(),
seeded.transport_keypair.public(),
"explicit path should override auto-persisted keypair"
);
}
}