use std::path::{Path, PathBuf};
use crate::file_signer::{FileSigner, PassphraseProvider};
use crate::signer::{IdentitySigner, RootSecret, SignerError};
pub struct IdentityVault {
signer: FileSigner,
path: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum VaultError {
#[error(
"identity file already exists at '{path}' — \
refusing to overwrite. Back up the existing identity first, \
or use a different path."
)]
AlreadyExists { path: String },
#[error(
"no identity file at '{path}' — \
run identity initialization first to create one."
)]
NotInitialized { path: String },
#[error(
"backup destination already exists at '{path}' — \
choose a different backup path to avoid overwriting."
)]
BackupExists { path: String },
#[error("{0}")]
Signer(#[from] SignerError),
#[error("backup failed: {0}")]
Io(#[from] std::io::Error),
}
impl IdentityVault {
pub fn new(path: impl Into<PathBuf>, provider: Box<dyn PassphraseProvider>) -> Self {
let path = path.into();
let signer = FileSigner::new(&path, provider);
Self { signer, path }
}
pub fn with_default_path(provider: Box<dyn PassphraseProvider>) -> Self {
Self::new(FileSigner::default_path(), provider)
}
pub fn exists(&self) -> bool {
self.path.exists()
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn init(&self, passphrase: &[u8]) -> Result<(), VaultError> {
match self.signer.generate(passphrase) {
Ok(()) => Ok(()),
Err(SignerError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
Err(VaultError::AlreadyExists { path: self.path.display().to_string() })
}
Err(e) => Err(VaultError::Signer(e)),
}
}
pub fn backup(&self, dest: impl AsRef<Path>) -> Result<(), VaultError> {
let dest = dest.as_ref();
if !self.path.exists() {
return Err(VaultError::NotInitialized { path: self.path.display().to_string() });
}
if dest.exists() {
return Err(VaultError::BackupExists { path: dest.display().to_string() });
}
let data = std::fs::read(&self.path)?;
if data.len() != crate::file_signer::FILE_LEN {
return Err(VaultError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"identity file is {} bytes, expected {} — file may be corrupted",
data.len(),
crate::file_signer::FILE_LEN
),
)));
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f =
std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(dest)?;
f.write_all(&data)?;
f.sync_all()?;
}
#[cfg(not(unix))]
{
std::fs::write(dest, &data)?;
}
Ok(())
}
pub async fn unlock(&self) -> Result<RootSecret, VaultError> {
if !self.path.exists() {
return Err(VaultError::NotInitialized { path: self.path.display().to_string() });
}
Ok(self.signer.root_secret().await?)
}
pub fn signer(&self) -> &FileSigner {
&self.signer
}
}
pub fn validate_agent_names(names: &[&str]) -> Vec<String> {
names
.iter()
.filter(|n| crate::derive::validate_label(n).is_err())
.map(|n| n.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::file_signer::ClosurePassphraseProvider;
fn test_vault(dir: &std::path::Path) -> IdentityVault {
let path = dir.join("identity.key");
IdentityVault::new(
path,
Box::new(ClosurePassphraseProvider::new(|| Ok(b"test-passphrase".to_vec()))),
)
}
#[test]
fn init_creates_identity() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
assert!(!vault.exists());
vault.init(b"test-passphrase").unwrap();
assert!(vault.exists());
}
#[test]
fn init_refuses_overwrite() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
vault.init(b"test-passphrase").unwrap();
let err = vault.init(b"test-passphrase").unwrap_err();
assert!(
err.to_string().contains("already exists"),
"error should mention existing file: {err}"
);
}
#[test]
fn backup_creates_copy() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
vault.init(b"test-passphrase").unwrap();
let backup_path = dir.path().join("identity.key.bak");
vault.backup(&backup_path).unwrap();
assert!(backup_path.exists());
assert_eq!(std::fs::read(vault.path()).unwrap(), std::fs::read(&backup_path).unwrap());
}
#[test]
fn backup_refuses_overwrite() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
vault.init(b"test-passphrase").unwrap();
let backup_path = dir.path().join("identity.key.bak");
vault.backup(&backup_path).unwrap();
let err = vault.backup(&backup_path).unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[test]
fn backup_requires_existing_identity() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let err = vault.backup(dir.path().join("backup.key")).unwrap_err();
assert!(err.to_string().contains("no identity file"));
}
#[tokio::test]
async fn unlock_returns_root_secret() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
vault.init(b"test-passphrase").unwrap();
let root = vault.unlock().await.unwrap();
assert_ne!(root.as_bytes(), &[0u8; 32]);
}
#[tokio::test]
async fn unlock_requires_existing_identity() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let err = vault.unlock().await.unwrap_err();
assert!(err.to_string().contains("no identity file"));
}
#[test]
fn validate_agent_names_catches_empty() {
let invalid = validate_agent_names(&["omegon-primary", "", "cleave-0"]);
assert_eq!(invalid, vec![""]);
}
#[test]
fn validate_agent_names_all_valid() {
let invalid = validate_agent_names(&["omegon-primary", "cleave-0", "auspex"]);
assert!(invalid.is_empty());
}
#[cfg(unix)]
#[test]
fn backup_has_restricted_permissions() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
vault.init(b"test-passphrase").unwrap();
let backup_path = dir.path().join("identity.key.bak");
vault.backup(&backup_path).unwrap();
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&backup_path).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
}
}