use std::fs;
use std::path::{Path, PathBuf};
use age::x25519;
use tracing::{debug, warn};
use crate::core::constants;
use crate::core::types::PublicKey;
use crate::error::{Result, StoreError, ValidationError};
pub struct Identity {
inner: x25519::Identity,
path: PathBuf,
}
impl Identity {
pub fn load(key_dir: &Path) -> Result<Self> {
let key_path = key_dir.join("identity.key");
debug!(path = %key_path.display(), "loading identity");
if !key_path.exists() {
return Err(StoreError::NoPrivateKey(key_dir.display().to_string()).into());
}
#[cfg(unix)]
{
if Self::validate_file_permissions(&key_path, 0o600).is_err() {
let metadata = fs::metadata(&key_path).ok();
let mode = metadata
.map(|m| {
use std::os::unix::fs::PermissionsExt;
format!("{:o}", m.permissions().mode() & 0o777)
})
.unwrap_or_else(|| "unknown".to_string());
warn!(
path = %key_path.display(),
mode = %mode,
"insecure key file permissions"
);
}
}
let contents = fs::read_to_string(&key_path).map_err(StoreError::ReadFailed)?;
let inner: x25519::Identity = contents
.trim()
.parse()
.map_err(|e: &str| StoreError::InvalidFormat(e.to_string()))?;
debug!("identity loaded");
Ok(Self {
inner,
path: key_path,
})
}
pub fn generate(key_dir: &Path) -> Result<Self> {
debug!(path = %key_dir.display(), "generating new identity");
let inner = x25519::Identity::generate();
fs::create_dir_all(key_dir).map_err(StoreError::WriteFailed)?;
let key_path = key_dir.join("identity.key");
use age::secrecy::ExposeSecret;
let secret_str = inner.to_string();
fs::write(&key_path, format!("{}\n", secret_str.expose_secret()))
.map_err(StoreError::WriteFailed)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))
.map_err(StoreError::WriteFailed)?;
}
debug!(path = %key_path.display(), "identity saved");
Ok(Self {
inner,
path: key_path,
})
}
pub fn public_key(&self) -> PublicKey {
self.inner.to_public().to_string()
}
pub fn as_age(&self) -> &x25519::Identity {
&self.inner
}
pub fn path(&self) -> &Path {
&self.path
}
fn base_dir() -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| {
StoreError::GenerationFailed("unable to determine home directory".to_string())
})?;
Ok(home.join(constants::KEY_DIR))
}
pub fn project_dir(project_id: &str) -> Result<PathBuf> {
Ok(Self::base_dir()?.join(project_id))
}
pub fn global_dir() -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| {
StoreError::GenerationFailed("unable to determine home directory".to_string())
})?;
Ok(home.join(".dugout"))
}
pub fn global_path() -> Result<PathBuf> {
Ok(Self::global_dir()?.join("identity"))
}
pub fn global_pubkey_path() -> Result<PathBuf> {
Ok(Self::global_dir()?.join("identity.pub"))
}
pub fn has_global() -> Result<bool> {
Ok(Self::global_path()?.exists())
}
pub fn load_global() -> Result<Self> {
let global_dir = Self::global_dir()?;
let key_path = Self::global_path()?;
if !key_path.exists() {
return Err(StoreError::NoPrivateKey(global_dir.display().to_string()).into());
}
#[cfg(unix)]
{
if Self::validate_file_permissions(&key_path, 0o600).is_err() {
let metadata = fs::metadata(&key_path).ok();
let mode = metadata
.map(|m| {
use std::os::unix::fs::PermissionsExt;
format!("{:o}", m.permissions().mode() & 0o777)
})
.unwrap_or_else(|| "unknown".to_string());
warn!(
path = %key_path.display(),
mode = %mode,
"insecure key file permissions"
);
}
}
let contents = fs::read_to_string(&key_path).map_err(StoreError::ReadFailed)?;
let inner: x25519::Identity = contents
.trim()
.parse()
.map_err(|e: &str| StoreError::InvalidFormat(e.to_string()))?;
debug!("global identity loaded");
Ok(Self {
inner,
path: key_path,
})
}
pub fn generate_global() -> Result<Self> {
let global_dir = Self::global_dir()?;
debug!(path = %global_dir.display(), "generating global identity");
let inner = x25519::Identity::generate();
fs::create_dir_all(&global_dir).map_err(StoreError::WriteFailed)?;
let key_path = Self::global_path()?;
let pubkey_path = Self::global_pubkey_path()?;
use age::secrecy::ExposeSecret;
let secret_str = inner.to_string();
fs::write(&key_path, format!("{}\n", secret_str.expose_secret()))
.map_err(StoreError::WriteFailed)?;
let pubkey = inner.to_public().to_string();
fs::write(&pubkey_path, format!("{}\n", pubkey)).map_err(StoreError::WriteFailed)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))
.map_err(StoreError::WriteFailed)?;
fs::set_permissions(&pubkey_path, fs::Permissions::from_mode(0o644))
.map_err(StoreError::WriteFailed)?;
}
debug!(path = %key_path.display(), "global identity saved");
Ok(Self {
inner,
path: key_path,
})
}
pub fn load_global_pubkey() -> Result<PublicKey> {
let pubkey_path = Self::global_pubkey_path()?;
if !pubkey_path.exists() {
return Err(StoreError::NoPrivateKey("~/.dugout/identity".to_string()).into());
}
let contents = fs::read_to_string(&pubkey_path).map_err(StoreError::ReadFailed)?;
Ok(contents.trim().to_string())
}
#[cfg(unix)]
fn validate_file_permissions(path: &Path, expected_mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(path)?;
let actual_mode = metadata.permissions().mode() & 0o777;
if actual_mode != expected_mode {
return Err(ValidationError::InvalidPermissions {
path: path.display().to_string(),
expected: format!("{:o}", expected_mode),
actual: format!("{:o}", actual_mode),
}
.into());
}
Ok(())
}
}
impl std::fmt::Debug for Identity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Identity")
.field("path", &self.path)
.field("public_key", &self.public_key())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_generate_and_load() {
let tmp = TempDir::new().unwrap();
let key_dir = tmp.path().join("test-project");
let identity1 = Identity::generate(&key_dir).unwrap();
let pubkey1 = identity1.public_key();
let identity2 = Identity::load(&key_dir).unwrap();
let pubkey2 = identity2.public_key();
assert_eq!(pubkey1, pubkey2);
}
#[test]
fn test_public_key() {
let tmp = TempDir::new().unwrap();
let key_dir = tmp.path().join("test-project");
let identity = Identity::generate(&key_dir).unwrap();
let pubkey = identity.public_key();
assert!(pubkey.starts_with("age1"));
}
#[test]
fn test_path() {
let tmp = TempDir::new().unwrap();
let key_dir = tmp.path().join("test-project");
let identity = Identity::generate(&key_dir).unwrap();
let path = identity.path();
assert!(path.exists());
assert!(path.ends_with("identity.key"));
}
}