use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, PoisonError, RwLock};
use chrono::{DateTime, Utc};
use engenho_control_types::pin::{KeyMaterial, Presented, Spki};
use engenho_substrate_core::write_atomic_mode;
pub const KEY: &str = "key.pem";
pub const DIR: &str = "identity";
#[derive(Debug)]
pub struct ControlIdentity {
path: PathBuf,
current: RwLock<Current>,
presented: Arc<Presented>,
}
#[derive(Debug, Clone, Copy)]
struct Current {
spki: Spki,
created_at: DateTime<Utc>,
}
#[derive(Debug, thiserror::Error)]
pub enum IdentityError {
#[error("{}: {source}", path.display())]
Io {
path: PathBuf,
source: io::Error,
},
#[error("{}: {why}", path.display())]
Key {
path: PathBuf,
why: String,
},
#[error("{}: mode {mode:04o} lets others read the control identity's private key; `chmod 600` it", path.display())]
Exposed {
path: PathBuf,
mode: u32,
},
}
impl ControlIdentity {
pub fn load_or_create(dir: &Path) -> Result<Self, IdentityError> {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(KEY);
let io = |source| IdentityError::Io {
path: path.clone(),
source,
};
let (key, created_at) = match std::fs::metadata(&path) {
Ok(meta) => {
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(IdentityError::Exposed { path, mode });
}
let pem = std::fs::read_to_string(&path).map_err(io)?;
let key = KeyMaterial::from_pem(&pem).map_err(|e| key_error(&path, &e))?;
let created_at = meta
.modified()
.map_or_else(|_| Utc::now(), DateTime::<Utc>::from);
(key, created_at)
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
std::fs::create_dir_all(dir).map_err(io)?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.map_err(io)?;
let key = KeyMaterial::generate().map_err(|e| key_error(&path, &e))?;
write_key(&path, &key)?;
tracing::info!(spki = %key.spki(), "created the control identity");
(key, Utc::now())
}
Err(err) => return Err(io(err)),
};
let presented = Presented::new(&key).map_err(|e| key_error(&path, &e))?;
Ok(Self {
current: RwLock::new(Current {
spki: key.spki(),
created_at,
}),
path,
presented: Arc::new(presented),
})
}
pub fn rotate(&self, keep_old_at: &Path) -> Result<Spki, IdentityError> {
let key = KeyMaterial::generate().map_err(|e| key_error(&self.path, &e))?;
let io = |path: &Path, source| IdentityError::Io {
path: path.to_path_buf(),
source,
};
if let Some(parent) = keep_old_at.parent() {
std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
}
std::fs::rename(&self.path, keep_old_at).map_err(|e| io(&self.path, e))?;
let put_back = || std::fs::rename(keep_old_at, &self.path);
if let Err(err) = write_key(&self.path, &key) {
let _ = put_back();
return Err(err);
}
if let Err(e) = self.presented.present(&key) {
let _ = put_back();
return Err(key_error(&self.path, &e));
}
let spki = key.spki();
*self.current.write().unwrap_or_else(PoisonError::into_inner) = Current {
spki,
created_at: Utc::now(),
};
tracing::info!(%spki, old = %keep_old_at.display(), "rotated the control identity");
Ok(spki)
}
fn current(&self) -> Current {
*self.current.read().unwrap_or_else(PoisonError::into_inner)
}
#[must_use]
pub fn spki(&self) -> Spki {
self.current().spki
}
#[must_use]
pub fn created_at(&self) -> DateTime<Utc> {
self.current().created_at
}
#[must_use]
pub fn presented(&self) -> Arc<Presented> {
Arc::clone(&self.presented)
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
fn key_error(path: &Path, why: &impl std::fmt::Display) -> IdentityError {
IdentityError::Key {
path: path.to_path_buf(),
why: why.to_string(),
}
}
fn write_key(path: &Path, key: &KeyMaterial) -> Result<(), IdentityError> {
write_atomic_mode(path, key.to_pem().as_bytes(), 0o600).map_err(|e| IdentityError::Io {
path: path.to_path_buf(),
source: io::Error::other(e.to_string()),
})
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use super::*;
#[test]
fn created_once_private_and_stable() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("control").join(DIR);
let first = ControlIdentity::load_or_create(&dir).unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&dir), 0o700);
assert_eq!(mode(first.path()), 0o600);
let again = ControlIdentity::load_or_create(&dir).unwrap();
assert_eq!(again.spki(), first.spki(), "the same key, not a new one");
}
#[test]
fn a_key_others_can_read_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let id = ControlIdentity::load_or_create(tmp.path()).unwrap();
std::fs::set_permissions(id.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(matches!(
ControlIdentity::load_or_create(tmp.path()),
Err(IdentityError::Exposed { mode: 0o644, .. })
));
}
#[test]
fn a_rotation_keeps_the_old_key_and_replaces_it() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(DIR);
let id = ControlIdentity::load_or_create(&dir).unwrap();
let old = id.spki();
let old_pem = std::fs::read_to_string(id.path()).unwrap();
let kept = tmp.path().join("attic").join(DIR).join(KEY);
let new = id.rotate(&kept).unwrap();
assert_ne!(new, old);
assert_eq!(id.spki(), new);
assert_eq!(
std::fs::read_to_string(&kept).unwrap(),
old_pem,
"the old key was kept"
);
let mode = std::fs::metadata(id.path()).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
assert_eq!(ControlIdentity::load_or_create(&dir).unwrap().spki(), new);
}
#[test]
fn a_failed_rotation_leaves_the_key_in_force() {
let tmp = tempfile::tempdir().unwrap();
let id = ControlIdentity::load_or_create(tmp.path()).unwrap();
let old = id.spki();
let blocker = tmp.path().join("blocker");
std::fs::write(&blocker, "x").unwrap();
assert!(id.rotate(&blocker.join(KEY)).is_err());
assert_eq!(id.spki(), old);
assert_eq!(
ControlIdentity::load_or_create(tmp.path()).unwrap().spki(),
old
);
}
#[test]
fn a_garbage_key_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(KEY);
std::fs::write(&path, "not a key").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(matches!(
ControlIdentity::load_or_create(tmp.path()),
Err(IdentityError::Key { .. })
));
}
}