use std::path::{Path, PathBuf};
use ed25519_dalek::SigningKey as Ed25519SigningKey;
use ed25519_dalek::pkcs8::EncodePublicKey;
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey};
use greentic_distributor_client::signing::{SigningError, key_id_for_public_key_pem};
use rand::TryRngCore;
use rand::rngs::OsRng;
use thiserror::Error;
use zeroize::Zeroizing;
use crate::environment::store::dirs_home;
pub const OPERATOR_KEY_PATH_ENV: &str = "GTC_OPERATOR_KEY_PATH";
#[derive(Debug, Error)]
pub enum OperatorKeyError {
#[error(
"cannot resolve operator key path: `${OPERATOR_KEY_PATH_ENV}` is unset and no home directory is available"
)]
NoHome,
#[error("operator key io on {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("operator key parse: {0}")]
KeyDecode(String),
#[error("operator key derivation: {0}")]
Signing(#[from] SigningError),
#[error(
"operator public key `{pub_path}` is stale: id `{pub_id}` does not match private-key id `{priv_id}` — delete the `.pub` and re-run to regenerate, or restore the matching private key"
)]
StalePublicKey {
pub_path: PathBuf,
pub_id: String,
priv_id: String,
},
#[error("operator key entropy: {0}")]
Entropy(String),
#[error(
"operator key `{path}` has insecure permissions (mode {mode:#o}); expected mode `0600` (owner-only). Restore with `chmod 600 {path}` or delete and regenerate."
)]
InsecurePermissions { path: PathBuf, mode: u32 },
#[error(
"operator key `{path}` is not a regular file (symlinks, directories, FIFOs etc. are rejected)"
)]
NotRegularFile { path: PathBuf },
#[error(
"operator key path `{path}`: ancestor `{ancestor}` is a symlink. Re-create the directory as a real path (e.g. `mv {ancestor} {ancestor}.symlink && mkdir -p {ancestor}`) to prevent intermediate-symlink redirection."
)]
SymlinkInAncestor { path: PathBuf, ancestor: PathBuf },
}
pub struct OperatorKey {
pub path: PathBuf,
pub private_pem: Zeroizing<String>,
pub public_pem: String,
pub key_id: String,
}
impl std::fmt::Debug for OperatorKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OperatorKey")
.field("path", &self.path)
.field("private_pem", &"[REDACTED]")
.field("public_pem_len", &self.public_pem.len())
.field("key_id", &self.key_id)
.finish()
}
}
pub fn resolve_path() -> Result<PathBuf, OperatorKeyError> {
resolve_path_with(std::env::var_os(OPERATOR_KEY_PATH_ENV), dirs_home())
}
pub(crate) fn resolve_path_with(
override_path: Option<std::ffi::OsString>,
home: Option<PathBuf>,
) -> Result<PathBuf, OperatorKeyError> {
if let Some(p) = override_path
&& !p.is_empty()
{
return Ok(PathBuf::from(p));
}
home.map(|h| h.join(".greentic").join("operator").join("key.pem"))
.ok_or(OperatorKeyError::NoHome)
}
pub fn load_or_generate() -> Result<OperatorKey, OperatorKeyError> {
let path = resolve_path()?;
load_or_generate_at(&path)
}
pub fn load_existing_only() -> Result<OperatorKey, OperatorKeyError> {
let path = resolve_path()?;
refuse_symlink_in_ancestors(&path)?;
let pem = read_existing_securely(&path)?;
load_existing(&path, pem)
}
pub fn load_or_generate_at(path: &Path) -> Result<OperatorKey, OperatorKeyError> {
refuse_symlink_in_ancestors(path)?;
match read_existing_securely(path) {
Ok(private_pem) => load_existing(path, private_pem),
Err(OperatorKeyError::Io { source, .. })
if source.kind() == std::io::ErrorKind::NotFound =>
{
generate_at(path)
}
Err(other) => Err(other),
}
}
fn refuse_symlink_in_ancestors(path: &Path) -> Result<(), OperatorKeyError> {
let mut ancestor = path.parent();
while let Some(p) = ancestor {
if p.as_os_str().is_empty() {
break;
}
match std::fs::symlink_metadata(p) {
Ok(meta) => {
if meta.file_type().is_symlink() {
return Err(OperatorKeyError::SymlinkInAncestor {
path: path.to_path_buf(),
ancestor: p.to_path_buf(),
});
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
}
Err(source) => {
return Err(OperatorKeyError::Io {
path: p.to_path_buf(),
source,
});
}
}
ancestor = p.parent();
}
Ok(())
}
fn read_existing_securely(path: &Path) -> Result<Zeroizing<String>, OperatorKeyError> {
let file = open_no_follow(path)?;
let meta = file.metadata().map_err(|source| OperatorKeyError::Io {
path: path.to_path_buf(),
source,
})?;
if !meta.is_file() {
return Err(OperatorKeyError::NotRegularFile {
path: path.to_path_buf(),
});
}
check_mode(path, &meta)?;
let len = meta.len().try_into().unwrap_or(usize::MAX);
let mut contents = Zeroizing::new(String::with_capacity(len.saturating_add(8)));
use std::io::Read;
{
let mut handle = file;
handle
.read_to_string(&mut contents)
.map_err(|source| OperatorKeyError::Io {
path: path.to_path_buf(),
source,
})?;
}
Ok(contents)
}
#[cfg(unix)]
fn open_no_follow(path: &Path) -> Result<std::fs::File, OperatorKeyError> {
use std::os::unix::fs::OpenOptionsExt;
let result = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path);
match result {
Ok(f) => Ok(f),
Err(e) => {
#[allow(clippy::manual_map)]
if let Some(raw) = e.raw_os_error()
&& raw == libc::ELOOP
{
return Err(OperatorKeyError::NotRegularFile {
path: path.to_path_buf(),
});
}
Err(OperatorKeyError::Io {
path: path.to_path_buf(),
source: e,
})
}
}
}
#[cfg(not(unix))]
fn open_no_follow(path: &Path) -> Result<std::fs::File, OperatorKeyError> {
std::fs::OpenOptions::new()
.read(true)
.open(path)
.map_err(|source| OperatorKeyError::Io {
path: path.to_path_buf(),
source,
})
}
#[cfg(unix)]
fn check_mode(path: &Path, meta: &std::fs::Metadata) -> Result<(), OperatorKeyError> {
use std::os::unix::fs::MetadataExt;
let mode = meta.mode() & 0o777;
if mode & 0o077 != 0 {
return Err(OperatorKeyError::InsecurePermissions {
path: path.to_path_buf(),
mode,
});
}
Ok(())
}
#[cfg(not(unix))]
fn check_mode(_path: &Path, _meta: &std::fs::Metadata) -> Result<(), OperatorKeyError> {
Ok(())
}
fn load_existing(
path: &Path,
private_pem: Zeroizing<String>,
) -> Result<OperatorKey, OperatorKeyError> {
let sk = Ed25519SigningKey::from_pkcs8_pem(&private_pem)
.map_err(|e| OperatorKeyError::KeyDecode(format!("PKCS#8 private PEM: {e}")))?;
let vk = sk.verifying_key();
let public_pem = vk
.to_public_key_pem(LineEnding::LF)
.map_err(|e| OperatorKeyError::KeyDecode(format!("derive SPKI PEM: {e}")))?;
drop(sk);
let key_id = key_id_for_public_key_pem(&public_pem)?;
let pub_path = public_sibling(path);
match std::fs::read_to_string(&pub_path) {
Ok(existing_pub) => {
let existing_id = key_id_for_public_key_pem(&existing_pub).map_err(|e| {
OperatorKeyError::KeyDecode(format!(
"`.pub` sibling at {}: {e}",
pub_path.display()
))
})?;
if !existing_id.eq_ignore_ascii_case(&key_id) {
return Err(OperatorKeyError::StalePublicKey {
pub_path,
pub_id: existing_id,
priv_id: key_id,
});
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
write_public_sibling(&pub_path, &public_pem)?;
}
Err(source) => {
return Err(OperatorKeyError::Io {
path: pub_path,
source,
});
}
}
Ok(OperatorKey {
path: path.to_path_buf(),
private_pem,
public_pem,
key_id,
})
}
fn generate_at(path: &Path) -> Result<OperatorKey, OperatorKeyError> {
let mut seed = Zeroizing::new([0u8; 32]);
OsRng
.try_fill_bytes(&mut seed[..])
.map_err(|e| OperatorKeyError::Entropy(e.to_string()))?;
let sk = Ed25519SigningKey::from_bytes(&seed);
let vk = sk.verifying_key();
let private_pem: Zeroizing<String> = Zeroizing::new(
sk.to_pkcs8_pem(LineEnding::LF)
.map_err(|e| OperatorKeyError::KeyDecode(format!("encode PKCS#8 PEM: {e}")))?
.to_string(),
);
let public_pem = vk
.to_public_key_pem(LineEnding::LF)
.map_err(|e| OperatorKeyError::KeyDecode(format!("encode SPKI PEM: {e}")))?;
drop(sk);
let key_id = key_id_for_public_key_pem(&public_pem)?;
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|source| OperatorKeyError::Io {
path: parent.to_path_buf(),
source,
})?;
}
match write_private_exclusive(path, &private_pem) {
Ok(()) => {
let pub_path = public_sibling(path);
let _ = write_public_sibling_exclusive(&pub_path, &public_pem);
Ok(OperatorKey {
path: path.to_path_buf(),
private_pem,
public_pem,
key_id,
})
}
Err(OperatorKeyError::Io { source, .. })
if source.kind() == std::io::ErrorKind::AlreadyExists =>
{
let existing = read_existing_securely(path)?;
load_existing(path, existing)
}
Err(other) => Err(other),
}
}
fn public_sibling(private_path: &Path) -> PathBuf {
let mut s = private_path.as_os_str().to_owned();
s.push(".pub");
PathBuf::from(s)
}
fn write_exclusive(path: &Path, contents: &str, mode: u32) -> Result<(), OperatorKeyError> {
use std::io::Write;
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::Path::new(".").to_path_buf());
let mut tmp =
tempfile::NamedTempFile::new_in(&parent).map_err(|source| OperatorKeyError::Io {
path: parent.clone(),
source,
})?;
tmp.write_all(contents.as_bytes())
.map_err(|source| OperatorKeyError::Io {
path: tmp.path().to_path_buf(),
source,
})?;
tmp.flush().map_err(|source| OperatorKeyError::Io {
path: tmp.path().to_path_buf(),
source,
})?;
set_mode(&tmp, mode)?;
tmp.as_file()
.sync_all()
.map_err(|source| OperatorKeyError::Io {
path: tmp.path().to_path_buf(),
source,
})?;
tmp.persist_noclobber(path)
.map_err(|e| OperatorKeyError::Io {
path: path.to_path_buf(),
source: e.error,
})?;
Ok(())
}
#[cfg(unix)]
fn set_mode(tmp: &tempfile::NamedTempFile, mode: u32) -> Result<(), OperatorKeyError> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(mode);
std::fs::set_permissions(tmp.path(), perms).map_err(|source| OperatorKeyError::Io {
path: tmp.path().to_path_buf(),
source,
})
}
#[cfg(not(unix))]
fn set_mode(_tmp: &tempfile::NamedTempFile, _mode: u32) -> Result<(), OperatorKeyError> {
Ok(())
}
fn write_private_exclusive(path: &Path, contents: &str) -> Result<(), OperatorKeyError> {
write_exclusive(path, contents, 0o600)
}
fn write_public_sibling_exclusive(path: &Path, contents: &str) -> Result<(), OperatorKeyError> {
write_exclusive(path, contents, 0o644)
}
fn write_public_sibling(path: &Path, contents: &str) -> Result<(), OperatorKeyError> {
match write_public_sibling_exclusive(path, contents) {
Ok(()) => Ok(()),
Err(OperatorKeyError::Io { source, .. })
if source.kind() == std::io::ErrorKind::AlreadyExists =>
{
Ok(())
}
Err(other) => Err(other),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[cfg(unix)]
fn chmod(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
}
#[test]
fn generate_creates_keypair_with_canonical_id() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
let key = load_or_generate_at(&path).unwrap();
assert_eq!(key.key_id.len(), 32);
assert!(key.private_pem.contains("BEGIN PRIVATE KEY"));
assert!(key.public_pem.contains("BEGIN PUBLIC KEY"));
assert!(path.is_file(), "private key file must exist");
let pub_path = public_sibling(&path);
assert!(pub_path.is_file(), "public sibling must exist");
let pub_id =
key_id_for_public_key_pem(&std::fs::read_to_string(&pub_path).unwrap()).unwrap();
assert_eq!(pub_id, key.key_id);
}
#[test]
fn load_is_idempotent_after_generate() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
let first = load_or_generate_at(&path).unwrap();
let second = load_or_generate_at(&path).unwrap();
assert_eq!(first.key_id, second.key_id);
assert_eq!(first.public_pem, second.public_pem);
}
#[cfg(unix)]
#[test]
fn private_key_is_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
load_or_generate_at(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn stale_pub_sibling_is_rejected() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
load_or_generate_at(&path).unwrap();
let other_sk = Ed25519SigningKey::from_bytes(&[7u8; 32]);
let other_pub = other_sk
.verifying_key()
.to_public_key_pem(LineEnding::LF)
.unwrap();
let pub_path = public_sibling(&path);
std::fs::write(&pub_path, &other_pub).unwrap();
let err = load_or_generate_at(&path).expect_err("stale pub must be rejected");
assert!(matches!(err, OperatorKeyError::StalePublicKey { .. }));
}
#[test]
fn missing_pub_sibling_is_regenerated_on_load() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
load_or_generate_at(&path).unwrap();
let pub_path = public_sibling(&path);
std::fs::remove_file(&pub_path).unwrap();
let key = load_or_generate_at(&path).unwrap();
assert!(pub_path.is_file(), "pub sibling must be regenerated");
let pub_id =
key_id_for_public_key_pem(&std::fs::read_to_string(&pub_path).unwrap()).unwrap();
assert_eq!(pub_id, key.key_id);
}
#[test]
fn env_override_takes_precedence() {
let dir = tempdir().unwrap();
let path = dir.path().join("override.pem");
let resolved = resolve_path_with(
Some(path.as_os_str().to_owned()),
Some(PathBuf::from("/should-not-be-used")),
)
.unwrap();
assert_eq!(resolved, path);
}
#[test]
fn empty_env_override_falls_through_to_home() {
let home = PathBuf::from("/home/op");
let resolved =
resolve_path_with(Some(std::ffi::OsString::new()), Some(home.clone())).unwrap();
assert_eq!(
resolved,
home.join(".greentic").join("operator").join("key.pem")
);
}
#[test]
fn missing_env_and_missing_home_is_no_home_error() {
let err = resolve_path_with(None, None).expect_err("must error");
assert!(matches!(err, OperatorKeyError::NoHome));
}
#[test]
fn debug_redacts_private_pem() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
let key = load_or_generate_at(&path).unwrap();
let dbg = format!("{key:?}");
assert!(dbg.contains("[REDACTED]"));
assert!(!dbg.contains("BEGIN PRIVATE KEY"));
}
#[test]
fn concurrent_generates_converge_on_one_key() {
let dir = tempdir().unwrap();
let path = std::sync::Arc::new(dir.path().join("key.pem"));
let handles: Vec<_> = (0..8)
.map(|_| {
let p = path.clone();
std::thread::spawn(move || load_or_generate_at(&p).unwrap().key_id)
})
.collect();
let ids: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
let first = &ids[0];
for id in &ids {
assert_eq!(id, first, "all racers must adopt one canonical key");
}
}
#[cfg(unix)]
#[test]
fn existing_key_with_world_readable_mode_is_rejected() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
load_or_generate_at(&path).unwrap();
chmod(&path, 0o644);
let err = load_or_generate_at(&path).expect_err("0644 must be rejected");
match err {
OperatorKeyError::InsecurePermissions { mode, .. } => assert_eq!(mode, 0o644),
other => panic!("expected InsecurePermissions, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn existing_key_with_group_readable_mode_is_rejected() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
load_or_generate_at(&path).unwrap();
chmod(&path, 0o660);
let err = load_or_generate_at(&path).expect_err("0660 must be rejected");
match err {
OperatorKeyError::InsecurePermissions { mode, .. } => assert_eq!(mode, 0o660),
other => panic!("expected InsecurePermissions, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn existing_key_with_mode_0600_is_accepted() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
let first = load_or_generate_at(&path).unwrap();
chmod(&path, 0o600);
let second = load_or_generate_at(&path).unwrap();
assert_eq!(first.key_id, second.key_id);
}
#[cfg(unix)]
#[test]
fn symlinked_key_path_is_rejected() {
let dir = tempdir().unwrap();
let real_path = dir.path().join("real-key.pem");
load_or_generate_at(&real_path).unwrap();
let link_path = dir.path().join("via-link.pem");
std::os::unix::fs::symlink(&real_path, &link_path).unwrap();
let err = load_or_generate_at(&link_path).expect_err("symlink target must be rejected");
assert!(
matches!(err, OperatorKeyError::NotRegularFile { .. }),
"expected NotRegularFile, got {err:?}"
);
}
#[cfg(unix)]
#[test]
fn symlinked_intermediate_directory_is_rejected() {
let dir = tempdir().unwrap();
let attacker_root = dir.path().join("evil");
std::fs::create_dir_all(&attacker_root).unwrap();
let symlinked_parent = dir.path().join("operator");
std::os::unix::fs::symlink(&attacker_root, &symlinked_parent).unwrap();
let key_path = symlinked_parent.join("key.pem");
let err =
load_or_generate_at(&key_path).expect_err("intermediate symlink must be rejected");
match err {
OperatorKeyError::SymlinkInAncestor { ancestor, .. } => {
assert_eq!(ancestor, symlinked_parent);
}
other => panic!("expected SymlinkInAncestor, got {other:?}"),
}
assert!(
!attacker_root.join("key.pem").exists(),
"load must refuse before any write reaches the symlinked dir"
);
}
#[cfg(unix)]
#[test]
fn directory_at_key_path_is_rejected() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
std::fs::create_dir(&path).unwrap();
let err = load_or_generate_at(&path).expect_err("directory must be rejected");
assert!(
matches!(err, OperatorKeyError::NotRegularFile { .. }),
"expected NotRegularFile, got {err:?}"
);
}
#[test]
fn corrupted_private_pem_is_rejected() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pem");
std::fs::write(
&path,
"-----BEGIN PRIVATE KEY-----\nnope\n-----END PRIVATE KEY-----\n",
)
.unwrap();
#[cfg(unix)]
chmod(&path, 0o600);
let err = load_or_generate_at(&path).expect_err("bad PEM must reject");
assert!(matches!(err, OperatorKeyError::KeyDecode(_)));
}
}