use std::env;
use std::fmt;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use secrecy::SecretBox;
pub struct SecretBytes(SecretBox<[u8]>);
impl SecretBytes {
pub fn from_vec(bytes: Vec<u8>) -> Self {
Self(SecretBox::from(bytes.into_boxed_slice()))
}
}
impl secrecy::ExposeSecret<[u8]> for SecretBytes {
fn expose_secret(&self) -> &[u8] {
secrecy::ExposeSecret::expose_secret(&self.0)
}
}
impl fmt::Debug for SecretBytes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretBytes(<secret>)")
}
}
#[derive(Debug, thiserror::Error)]
pub enum SecretsError {
#[error("secret '{key}' introuvable")]
NotFound {
key: String,
},
#[error("erreur I/O lors de la lecture du secret '{key}': {source}")]
Io {
key: String,
#[source]
source: io::Error,
},
#[error(
"permissions trop ouvertes sur le fichier secret '{key}' \
(attendu: mode ≤ 0o600, trouvé: 0o{mode:04o})"
)]
Permissions {
key: String,
mode: u32,
},
#[error("opération non supportée ou clé invalide pour ce SecretsProvider: {operation}")]
Unsupported {
operation: String,
},
}
pub trait SecretsProvider: Send + Sync {
fn get(&self, key: &str) -> Result<SecretBytes, SecretsError>;
}
pub struct EnvSecretsProvider;
impl EnvSecretsProvider {
pub(crate) fn env_var_name(key: &str) -> String {
let upper = key.to_uppercase().replace('-', "_");
format!("GRADATUM_SECRET_{upper}")
}
}
impl SecretsProvider for EnvSecretsProvider {
fn get(&self, key: &str) -> Result<SecretBytes, SecretsError> {
let var_name = Self::env_var_name(key);
match env::var(&var_name) {
Ok(val) => Ok(SecretBytes::from_vec(val.into_bytes())),
Err(env::VarError::NotPresent) => Err(SecretsError::NotFound {
key: key.to_string(),
}),
Err(env::VarError::NotUnicode(_)) => Err(SecretsError::Io {
key: key.to_string(),
source: io::Error::new(
io::ErrorKind::InvalidData,
"valeur de variable d'env non-UTF-8",
),
}),
}
}
}
pub struct FileSecretsProvider {
base_dir: PathBuf,
}
impl FileSecretsProvider {
pub fn new(base_dir: impl Into<PathBuf>) -> Self {
Self {
base_dir: base_dir.into(),
}
}
pub(crate) fn validate_key(key: &str) -> Result<(), SecretsError> {
if key.is_empty() {
return Err(SecretsError::Unsupported {
operation: "clé vide — la clé ne peut pas être une chaîne vide".to_string(),
});
}
if key.contains('/') {
return Err(SecretsError::Unsupported {
operation: format!(
"clé '{key}' invalide — les slashes '/' sont interdits (path traversal)"
),
});
}
if key.split('/').any(|c| c == "..") || key == ".." {
return Err(SecretsError::Unsupported {
operation: format!("clé '{key}' invalide — '..' est interdit (path traversal)"),
});
}
Ok(())
}
pub(crate) fn file_path(&self, key: &str) -> PathBuf {
self.base_dir.join(format!("{key}.secret"))
}
fn check_permissions(&self, key: &str, path: &Path) -> Result<(), SecretsError> {
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path).map_err(|e| SecretsError::Io {
key: key.to_string(),
source: e,
})?;
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
return Err(SecretsError::Permissions {
key: key.to_string(),
mode,
});
}
Ok(())
}
fn check_base_dir_permissions(&self, key: &str) -> Result<(), SecretsError> {
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(&self.base_dir).map_err(|e| SecretsError::Io {
key: key.to_string(),
source: e,
})?;
let mode = meta.permissions().mode();
if mode & 0o022 != 0 {
return Err(SecretsError::Permissions {
key: key.to_string(),
mode,
});
}
Ok(())
}
pub fn write_atomic(&self, key: &str, bytes: &[u8]) -> Result<(), SecretsError> {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
Self::validate_key(key)?;
let final_path = self.file_path(key);
let tmp_path = self.base_dir.join(format!("{key}.secret.tmp"));
if let Some(parent) = final_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| SecretsError::Io {
key: key.to_string(),
source: e,
})?;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(
|e| SecretsError::Io {
key: key.to_string(),
source: e,
},
)?;
}
let open_tmp = |path: &PathBuf| {
std::fs::OpenOptions::new()
.write(true)
.create_new(true) .mode(0o600)
.open(path)
.map_err(|e| SecretsError::Io {
key: key.to_string(),
source: e,
})
};
let mut f = match open_tmp(&tmp_path) {
Ok(f) => f,
Err(SecretsError::Io { ref source, .. })
if source.kind() == io::ErrorKind::AlreadyExists =>
{
let _ = std::fs::remove_file(&tmp_path);
open_tmp(&tmp_path)?
}
Err(e) => return Err(e),
};
f.write_all(bytes).map_err(|e| SecretsError::Io {
key: key.to_string(),
source: e,
})?;
f.flush().map_err(|e| SecretsError::Io {
key: key.to_string(),
source: e,
})?;
drop(f);
std::fs::rename(&tmp_path, &final_path).map_err(|e| SecretsError::Io {
key: key.to_string(),
source: e,
})?;
Ok(())
}
}
impl SecretsProvider for FileSecretsProvider {
fn get(&self, key: &str) -> Result<SecretBytes, SecretsError> {
Self::validate_key(key)?;
let path = self.file_path(key);
if !path.exists() {
return Err(SecretsError::NotFound {
key: key.to_string(),
});
}
self.check_base_dir_permissions(key)?;
self.check_permissions(key, &path)?;
let bytes = std::fs::read(&path).map_err(|e| SecretsError::Io {
key: key.to_string(),
source: e,
})?;
Ok(SecretBytes::from_vec(bytes))
}
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
fn tempdir_secure() -> TempDir {
let dir = TempDir::new().expect("tempdir");
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))
.expect("chmod 0o700 tempdir");
dir
}
#[test]
fn secret_bytes_debug_masque_le_contenu() {
let sb = SecretBytes::from_vec(vec![0xde, 0xad, 0xbe, 0xef]);
let debug_repr = format!("{:?}", sb);
assert!(
!debug_repr.contains("de"),
"Debug ne doit pas exposer les bytes"
);
assert!(
!debug_repr.contains("ad"),
"Debug ne doit pas exposer les bytes"
);
assert!(
debug_repr.contains("<secret>"),
"Debug doit afficher <secret>"
);
}
#[test]
fn secret_bytes_expose_secret_retourne_le_contenu() {
let original = vec![1u8, 2, 3, 4, 5];
let sb = SecretBytes::from_vec(original.clone());
assert_eq!(sb.expose_secret(), original.as_slice());
}
#[test]
fn env_provider_nom_de_variable_canonique() {
assert_eq!(
EnvSecretsProvider::env_var_name("jwt-signing-key"),
"GRADATUM_SECRET_JWT_SIGNING_KEY"
);
assert_eq!(
EnvSecretsProvider::env_var_name("my-secret"),
"GRADATUM_SECRET_MY_SECRET"
);
}
#[test]
fn env_provider_retourne_la_valeur_presente() {
let key = "test-key-env-present";
let var = EnvSecretsProvider::env_var_name(key);
std::env::remove_var(&var);
std::env::set_var(&var, "valeur-test");
let provider = EnvSecretsProvider;
let result = provider.get(key).expect("doit réussir");
assert_eq!(result.expose_secret(), b"valeur-test");
std::env::remove_var(&var);
}
#[test]
fn env_provider_retourne_not_found_si_absent() {
let key = "test-key-env-absent-xyz";
let var = EnvSecretsProvider::env_var_name(key);
std::env::remove_var(&var);
let provider = EnvSecretsProvider;
let err = provider.get(key).expect_err("doit échouer");
assert!(
matches!(err, SecretsError::NotFound { .. }),
"attendu NotFound, obtenu: {err:?}"
);
}
#[test]
fn file_provider_lit_un_fichier_existant_chmod_600() {
let dir = tempdir_secure();
let provider = FileSecretsProvider::new(dir.path());
let key = "ma-cle";
let contenu = b"bytes-secrets-42";
let chemin = provider.file_path(key);
std::fs::write(&chemin, contenu).expect("écriture");
std::fs::set_permissions(&chemin, std::fs::Permissions::from_mode(0o600))
.expect("chmod 600");
let result = provider.get(key).expect("doit réussir");
assert_eq!(result.expose_secret(), contenu);
}
#[test]
fn file_provider_refuse_perms_ouvertes_644() {
let dir = tempdir_secure();
let provider = FileSecretsProvider::new(dir.path());
let key = "cle-perms-ouvertes";
let chemin = provider.file_path(key);
std::fs::write(&chemin, b"secret").expect("écriture");
std::fs::set_permissions(&chemin, std::fs::Permissions::from_mode(0o644))
.expect("chmod 644");
let err = provider.get(key).expect_err("doit échouer");
assert!(
matches!(err, SecretsError::Permissions { .. }),
"attendu Permissions (fichier 644), obtenu: {err:?}"
);
}
#[test]
fn file_provider_refuse_perms_ouvertes_640() {
let dir = tempdir_secure();
let provider = FileSecretsProvider::new(dir.path());
let key = "cle-perms-640";
let chemin = provider.file_path(key);
std::fs::write(&chemin, b"secret").expect("écriture");
std::fs::set_permissions(&chemin, std::fs::Permissions::from_mode(0o640))
.expect("chmod 640");
let err = provider.get(key).expect_err("doit échouer");
assert!(
matches!(err, SecretsError::Permissions { .. }),
"attendu Permissions (fichier 640), obtenu: {err:?}"
);
}
#[test]
fn file_provider_retourne_not_found_si_absent() {
let dir = tempdir_secure();
let provider = FileSecretsProvider::new(dir.path());
let err = provider.get("cle-inexistante").expect_err("doit échouer");
assert!(
matches!(err, SecretsError::NotFound { .. }),
"attendu NotFound, obtenu: {err:?}"
);
}
#[test]
fn file_provider_write_atomic_cree_avec_chmod_600() {
let dir = TempDir::new().expect("tempdir");
let provider = FileSecretsProvider::new(dir.path());
let key = "cle-atomique";
let contenu = b"octet-secret-atomique";
provider
.write_atomic(key, contenu)
.expect("écriture atomique doit réussir");
let chemin = provider.file_path(key);
assert!(
chemin.exists(),
"le fichier doit exister après write_atomic"
);
let meta = std::fs::metadata(&chemin).expect("metadata");
let mode = meta.permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"permissions fichier doivent être 0o600, trouvé: 0o{mode:04o}"
);
let dir_meta = std::fs::metadata(dir.path()).expect("metadata dir");
let dir_mode = dir_meta.permissions().mode();
assert_eq!(
dir_mode & 0o777,
0o700,
"permissions répertoire doivent être 0o700, trouvé: 0o{dir_mode:04o}"
);
let lu = std::fs::read(&chemin).expect("lecture");
assert_eq!(lu, contenu);
}
#[test]
fn file_provider_write_atomic_puis_get_reussit() {
let dir = TempDir::new().expect("tempdir");
let provider = FileSecretsProvider::new(dir.path());
let key = "cle-roundtrip";
let contenu = b"roundtrip-bytes";
provider.write_atomic(key, contenu).expect("écriture");
let result = provider.get(key).expect("lecture après écriture");
assert_eq!(result.expose_secret(), contenu);
}
#[test]
fn file_provider_nom_de_fichier_canonique() {
let provider = FileSecretsProvider::new("/var/lib/gradatum/secrets");
assert_eq!(
provider.file_path("jwt-signing-key"),
std::path::PathBuf::from("/var/lib/gradatum/secrets/jwt-signing-key.secret")
);
}
#[test]
fn file_provider_get_refuse_repertoire_world_writable() {
let dir = TempDir::new().expect("tempdir");
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o777))
.expect("chmod 777");
let provider = FileSecretsProvider::new(dir.path());
let chemin = provider.file_path("cle-test");
std::fs::write(&chemin, b"secret").expect("écriture");
std::fs::set_permissions(&chemin, std::fs::Permissions::from_mode(0o600))
.expect("chmod 600 fichier");
let err = provider
.get("cle-test")
.expect_err("doit échouer (dir writable)");
assert!(
matches!(err, SecretsError::Permissions { .. }),
"attendu Permissions (répertoire writable), obtenu: {err:?}"
);
}
#[test]
fn file_provider_get_refuse_repertoire_group_writable() {
let dir = TempDir::new().expect("tempdir");
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o770))
.expect("chmod 770");
let provider = FileSecretsProvider::new(dir.path());
let chemin = provider.file_path("cle-grp-writable");
std::fs::write(&chemin, b"secret").expect("écriture");
std::fs::set_permissions(&chemin, std::fs::Permissions::from_mode(0o600))
.expect("chmod 600 fichier");
let err = provider
.get("cle-grp-writable")
.expect_err("doit échouer (dir group-writable)");
assert!(
matches!(err, SecretsError::Permissions { .. }),
"attendu Permissions (répertoire group-writable), obtenu: {err:?}"
);
}
#[test]
fn validate_key_refuse_cle_vide() {
let err = FileSecretsProvider::validate_key("").expect_err("doit refuser clé vide");
assert!(matches!(err, SecretsError::Unsupported { .. }));
}
#[test]
fn validate_key_refuse_slash() {
let err =
FileSecretsProvider::validate_key("../../etc/passwd").expect_err("doit refuser slash");
assert!(matches!(err, SecretsError::Unsupported { .. }));
}
#[test]
fn validate_key_refuse_slash_simple() {
let err = FileSecretsProvider::validate_key("a/b").expect_err("doit refuser slash simple");
assert!(matches!(err, SecretsError::Unsupported { .. }));
}
#[test]
fn validate_key_refuse_double_point() {
let err = FileSecretsProvider::validate_key("..").expect_err("doit refuser '..'");
assert!(matches!(err, SecretsError::Unsupported { .. }));
}
#[test]
fn validate_key_accepte_cles_valides() {
assert!(FileSecretsProvider::validate_key("jwt-signing-key").is_ok());
assert!(FileSecretsProvider::validate_key("ma-cle-123").is_ok());
assert!(FileSecretsProvider::validate_key("api_key").is_ok());
assert!(FileSecretsProvider::validate_key("secret").is_ok());
}
#[test]
fn get_refuse_cle_avec_slash() {
let dir = tempdir_secure();
let provider = FileSecretsProvider::new(dir.path());
let err = provider
.get("../../etc/passwd")
.expect_err("doit refuser path traversal via get()");
assert!(
matches!(err, SecretsError::Unsupported { .. }),
"attendu Unsupported, obtenu: {err:?}"
);
}
#[test]
fn write_atomic_refuse_cle_avec_slash() {
let dir = TempDir::new().expect("tempdir");
let provider = FileSecretsProvider::new(dir.path());
let err = provider
.write_atomic("../../etc/cron.d/evil", b"payload")
.expect_err("doit refuser path traversal via write_atomic()");
assert!(
matches!(err, SecretsError::Unsupported { .. }),
"attendu Unsupported, obtenu: {err:?}"
);
}
}