styrene_identity/
vault.rs1use std::path::{Path, PathBuf};
27
28use crate::file_signer::{FileSigner, PassphraseProvider};
29use crate::signer::{IdentitySigner, RootSecret, SignerError};
30
31pub struct IdentityVault {
33 signer: FileSigner,
34 path: PathBuf,
35}
36
37#[derive(Debug, thiserror::Error)]
39pub enum VaultError {
40 #[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 #[error(
50 "no identity file at '{path}' — \
51 run identity initialization first to create one."
52 )]
53 NotInitialized { path: String },
54
55 #[error(
57 "backup destination already exists at '{path}' — \
58 choose a different backup path to avoid overwriting."
59 )]
60 BackupExists { path: String },
61
62 #[error("{0}")]
64 Signer(#[from] SignerError),
65
66 #[error("backup failed: {0}")]
68 Io(#[from] std::io::Error),
69}
70
71impl IdentityVault {
72 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 pub fn with_default_path(provider: Box<dyn PassphraseProvider>) -> Self {
81 Self::new(FileSigner::default_path(), provider)
82 }
83
84 pub fn exists(&self) -> bool {
86 self.path.exists()
87 }
88
89 pub fn path(&self) -> &Path {
91 &self.path
92 }
93
94 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 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 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 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 pub fn signer(&self) -> &FileSigner {
169 &self.signer
170 }
171}
172
173pub 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}