Skip to main content

styrene_identity/
vault.rs

1//! Identity vault — safe lifecycle management for Styrene identities.
2//!
3//! Wraps [`FileSigner`] with guardrails:
4//! - `init()` refuses to overwrite an existing identity
5//! - `backup()` exports an encrypted copy before destructive operations
6//! - Clear error messages guide operators through each failure mode
7//! - Agent name and SSH label validation at config time (not derivation time)
8//!
9//! # Usage
10//!
11//! ```ignore
12//! use styrene_identity::vault::IdentityVault;
13//!
14//! let vault = IdentityVault::new("/etc/styrene/identity.key", provider);
15//!
16//! // First-time setup — refuses if file already exists
17//! vault.init(b"passphrase")?;
18//!
19//! // Backup before any risky operation
20//! vault.backup("/etc/styrene/identity.key.bak")?;
21//!
22//! // Derive keys safely
23//! let root = vault.unlock().await?;
24//! ```
25
26use std::path::{Path, PathBuf};
27
28use crate::file_signer::{FileSigner, PassphraseProvider};
29use crate::signer::{IdentitySigner, RootSecret, SignerError};
30
31/// Safe lifecycle wrapper around a file-based identity.
32pub struct IdentityVault {
33    signer: FileSigner,
34    path: PathBuf,
35}
36
37/// Errors specific to vault lifecycle operations.
38#[derive(Debug, thiserror::Error)]
39pub enum VaultError {
40    /// Attempted to initialize over an existing identity file.
41    #[error(
42        "identity file already exists at '{path}' — \
43         refusing to overwrite. Back up the existing identity first, \
44         or use a different path."
45    )]
46    AlreadyExists { path: String },
47
48    /// Identity file does not exist (need to call init() first).
49    #[error(
50        "no identity file at '{path}' — \
51         run identity initialization first to create one."
52    )]
53    NotInitialized { path: String },
54
55    /// Backup destination already exists.
56    #[error(
57        "backup destination already exists at '{path}' — \
58         choose a different backup path to avoid overwriting."
59    )]
60    BackupExists { path: String },
61
62    /// Underlying signer error.
63    #[error("{0}")]
64    Signer(#[from] SignerError),
65
66    /// I/O error during backup.
67    #[error("backup failed: {0}")]
68    Io(#[from] std::io::Error),
69}
70
71impl IdentityVault {
72    /// Create a vault for the given identity file path.
73    pub fn new(path: impl Into<PathBuf>, provider: Box<dyn PassphraseProvider>) -> Self {
74        let path = path.into();
75        let signer = FileSigner::new(&path, provider);
76        Self { signer, path }
77    }
78
79    /// Create a vault using the default identity path (`~/.config/styrene/identity.key`).
80    pub fn with_default_path(provider: Box<dyn PassphraseProvider>) -> Self {
81        Self::new(FileSigner::default_path(), provider)
82    }
83
84    /// Whether an identity file exists at this vault's path.
85    pub fn exists(&self) -> bool {
86        self.path.exists()
87    }
88
89    /// Path to the identity file.
90    pub fn path(&self) -> &Path {
91        &self.path
92    }
93
94    /// Initialize a new identity. **Refuses to overwrite** an existing file.
95    ///
96    /// This is the only way to create a new identity through the vault.
97    /// Uses `O_EXCL` (kernel-level atomic check) — no TOCTOU race.
98    /// If a file already exists, returns `VaultError::AlreadyExists` with
99    /// instructions to back up first.
100    pub fn init(&self, passphrase: &[u8]) -> Result<(), VaultError> {
101        match self.signer.generate(passphrase) {
102            Ok(()) => Ok(()),
103            Err(SignerError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
104                Err(VaultError::AlreadyExists { path: self.path.display().to_string() })
105            }
106            Err(e) => Err(VaultError::Signer(e)),
107        }
108    }
109
110    /// Create an encrypted backup copy of the identity file.
111    ///
112    /// The backup is a byte-for-byte copy of the encrypted file (not a
113    /// plaintext export). The backup destination must not already exist.
114    pub fn backup(&self, dest: impl AsRef<Path>) -> Result<(), VaultError> {
115        let dest = dest.as_ref();
116
117        if !self.path.exists() {
118            return Err(VaultError::NotInitialized { path: self.path.display().to_string() });
119        }
120        if dest.exists() {
121            return Err(VaultError::BackupExists { path: dest.display().to_string() });
122        }
123
124        // Read and validate source file size.
125        let data = std::fs::read(&self.path)?;
126        if data.len() != crate::file_signer::FILE_LEN {
127            return Err(VaultError::Io(std::io::Error::new(
128                std::io::ErrorKind::InvalidData,
129                format!(
130                    "identity file is {} bytes, expected {} — file may be corrupted",
131                    data.len(),
132                    crate::file_signer::FILE_LEN
133                ),
134            )));
135        }
136        if let Some(parent) = dest.parent() {
137            std::fs::create_dir_all(parent)?;
138        }
139
140        #[cfg(unix)]
141        {
142            use std::io::Write;
143            use std::os::unix::fs::OpenOptionsExt;
144            let mut f =
145                std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(dest)?;
146            f.write_all(&data)?;
147            f.sync_all()?;
148        }
149        #[cfg(not(unix))]
150        {
151            std::fs::write(dest, &data)?;
152        }
153
154        Ok(())
155    }
156
157    /// Unlock the identity — decrypt and return the root secret.
158    ///
159    /// The passphrase is obtained from the provider given at construction.
160    pub async fn unlock(&self) -> Result<RootSecret, VaultError> {
161        if !self.path.exists() {
162            return Err(VaultError::NotInitialized { path: self.path.display().to_string() });
163        }
164        Ok(self.signer.root_secret().await?)
165    }
166
167    /// Access the underlying signer (for use with `KeyDeriver`, SSH agent, etc.).
168    pub fn signer(&self) -> &FileSigner {
169        &self.signer
170    }
171}
172
173/// Validate a list of agent names at config time.
174///
175/// Returns a list of invalid names. Call this when loading agent config,
176/// before any derivation happens — catches empty names early with a
177/// clear error instead of a runtime panic.
178pub fn validate_agent_names(names: &[&str]) -> Vec<String> {
179    names
180        .iter()
181        .filter(|n| crate::derive::validate_label(n).is_err())
182        .map(|n| n.to_string())
183        .collect()
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::file_signer::ClosurePassphraseProvider;
190
191    fn test_vault(dir: &std::path::Path) -> IdentityVault {
192        let path = dir.join("identity.key");
193        IdentityVault::new(
194            path,
195            Box::new(ClosurePassphraseProvider::new(|| Ok(b"test-passphrase".to_vec()))),
196        )
197    }
198
199    #[test]
200    fn init_creates_identity() {
201        let dir = tempfile::tempdir().unwrap();
202        let vault = test_vault(dir.path());
203
204        assert!(!vault.exists());
205        vault.init(b"test-passphrase").unwrap();
206        assert!(vault.exists());
207    }
208
209    #[test]
210    fn init_refuses_overwrite() {
211        let dir = tempfile::tempdir().unwrap();
212        let vault = test_vault(dir.path());
213
214        vault.init(b"test-passphrase").unwrap();
215        let err = vault.init(b"test-passphrase").unwrap_err();
216        assert!(
217            err.to_string().contains("already exists"),
218            "error should mention existing file: {err}"
219        );
220    }
221
222    #[test]
223    fn backup_creates_copy() {
224        let dir = tempfile::tempdir().unwrap();
225        let vault = test_vault(dir.path());
226
227        vault.init(b"test-passphrase").unwrap();
228        let backup_path = dir.path().join("identity.key.bak");
229        vault.backup(&backup_path).unwrap();
230
231        assert!(backup_path.exists());
232        assert_eq!(std::fs::read(vault.path()).unwrap(), std::fs::read(&backup_path).unwrap());
233    }
234
235    #[test]
236    fn backup_refuses_overwrite() {
237        let dir = tempfile::tempdir().unwrap();
238        let vault = test_vault(dir.path());
239
240        vault.init(b"test-passphrase").unwrap();
241        let backup_path = dir.path().join("identity.key.bak");
242        vault.backup(&backup_path).unwrap();
243
244        let err = vault.backup(&backup_path).unwrap_err();
245        assert!(err.to_string().contains("already exists"));
246    }
247
248    #[test]
249    fn backup_requires_existing_identity() {
250        let dir = tempfile::tempdir().unwrap();
251        let vault = test_vault(dir.path());
252
253        let err = vault.backup(dir.path().join("backup.key")).unwrap_err();
254        assert!(err.to_string().contains("no identity file"));
255    }
256
257    #[tokio::test]
258    async fn unlock_returns_root_secret() {
259        let dir = tempfile::tempdir().unwrap();
260        let vault = test_vault(dir.path());
261
262        vault.init(b"test-passphrase").unwrap();
263        let root = vault.unlock().await.unwrap();
264        assert_ne!(root.as_bytes(), &[0u8; 32]);
265    }
266
267    #[tokio::test]
268    async fn unlock_requires_existing_identity() {
269        let dir = tempfile::tempdir().unwrap();
270        let vault = test_vault(dir.path());
271
272        let err = vault.unlock().await.unwrap_err();
273        assert!(err.to_string().contains("no identity file"));
274    }
275
276    #[test]
277    fn validate_agent_names_catches_empty() {
278        let invalid = validate_agent_names(&["omegon-primary", "", "cleave-0"]);
279        assert_eq!(invalid, vec![""]);
280    }
281
282    #[test]
283    fn validate_agent_names_all_valid() {
284        let invalid = validate_agent_names(&["omegon-primary", "cleave-0", "auspex"]);
285        assert!(invalid.is_empty());
286    }
287
288    #[cfg(unix)]
289    #[test]
290    fn backup_has_restricted_permissions() {
291        let dir = tempfile::tempdir().unwrap();
292        let vault = test_vault(dir.path());
293
294        vault.init(b"test-passphrase").unwrap();
295        let backup_path = dir.path().join("identity.key.bak");
296        vault.backup(&backup_path).unwrap();
297
298        use std::os::unix::fs::PermissionsExt;
299        let perms = std::fs::metadata(&backup_path).unwrap().permissions();
300        assert_eq!(perms.mode() & 0o777, 0o600);
301    }
302}