1use crate::error::StorageError;
7use directories::ProjectDirs;
8use ed25519_dalek::SigningKey;
9use serde::{Deserialize, Serialize};
10use std::fs::{self, OpenOptions};
11use std::io::Write;
12#[cfg(unix)]
13use std::os::unix::fs::OpenOptionsExt;
14use std::path::{Path, PathBuf};
15use stellar_strkey::Strkey;
16
17pub const DEFAULT_IDENTITY_NAME: &str = "default";
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Identity {
28 pub name: String,
29 pub public_key: String,
30}
31
32pub struct IdentityStore {
34 dir: PathBuf,
35}
36
37impl IdentityStore {
38 pub fn new() -> Result<Self, StorageError> {
45 if let Ok(dir) = std::env::var("SDKT_IDENTITY_DIR") {
46 if !dir.is_empty() {
47 return Self::with_dir(dir);
48 }
49 }
50
51 let proj_dirs = ProjectDirs::from("com", "SorobanDevKit", "sdkt").ok_or_else(|| {
52 StorageError::Io(std::io::Error::new(
53 std::io::ErrorKind::NotFound,
54 "No valid home directory found",
55 ))
56 })?;
57
58 Self::with_dir(proj_dirs.config_dir().join("identities"))
59 }
60
61 pub fn with_dir<P: AsRef<Path>>(dir: P) -> Result<Self, StorageError> {
63 let dir = dir.as_ref().to_path_buf();
64 if !dir.exists() {
65 fs::create_dir_all(&dir).map_err(StorageError::Io)?;
66 }
67 Ok(Self { dir })
68 }
69
70 pub fn generate(&self, name: &str) -> Result<Identity, StorageError> {
72 self.ensure_name_valid(name)?;
73
74 let mut secret = [0u8; 32];
75 getrandom::fill(&mut secret)
76 .map_err(|e| StorageError::Io(std::io::Error::other(e.to_string())))?;
77 let signing_key = SigningKey::from_bytes(&secret);
78 secret.fill(0);
79
80 self.save_key(name, &signing_key)
81 }
82
83 pub fn import(&self, name: &str, secret_key: &str) -> Result<Identity, StorageError> {
85 self.ensure_name_valid(name)?;
86
87 let key = stellar_strkey::ed25519::PrivateKey::from_string(secret_key).map_err(|e| {
88 StorageError::Io(std::io::Error::new(
89 std::io::ErrorKind::InvalidInput,
90 format!("Invalid StrKey: {}", e),
91 ))
92 })?;
93
94 let signing_key = SigningKey::from_bytes(&key.0);
95 self.save_key(name, &signing_key)
96 }
97
98 pub fn get(&self, name: &str) -> Result<Identity, StorageError> {
100 let path = self.dir.join(format!("{}.toml", name));
101 if !path.exists() {
102 return Err(StorageError::Io(std::io::Error::new(
103 std::io::ErrorKind::NotFound,
104 format!("Identity '{}' not found", name),
105 )));
106 }
107
108 let signing_key = self.load_key(&path)?;
109
110 Ok(Identity {
111 name: name.to_string(),
112 public_key: Strkey::PublicKeyEd25519(stellar_strkey::ed25519::PublicKey(
113 signing_key.verifying_key().to_bytes(),
114 ))
115 .to_string()
116 .as_str()
117 .to_string(),
118 })
119 }
120
121 pub fn resolve_signing_identity(&self, name: &str) -> Result<Identity, StorageError> {
134 if name == DEFAULT_IDENTITY_NAME {
135 return self
136 .get_default()
137 .map_err(|_| StorageError::NoDefaultIdentity);
138 }
139
140 self.get(name)
141 }
142
143 pub fn remove(&self, name: &str) -> Result<(), StorageError> {
149 let path = self.dir.join(format!("{}.toml", name));
150 if !path.exists() {
151 return Err(StorageError::NotFound(format!(
152 "Identity '{}' not found",
153 name
154 )));
155 }
156 fs::remove_file(path).map_err(StorageError::Io)?;
157
158 let default_path = self.dir.join("default");
160 if default_path.exists() {
161 if let Ok(target) = fs::read_link(&default_path) {
162 if target.file_stem().and_then(|s| s.to_str()) == Some(name) {
163 let _ = fs::remove_file(default_path);
164 }
165 }
166 }
167 Ok(())
168 }
169
170 pub fn list(&self) -> Result<Vec<Identity>, StorageError> {
172 let mut identities = Vec::new();
173
174 for entry in fs::read_dir(&self.dir).map_err(StorageError::Io)? {
175 let entry = entry.map_err(StorageError::Io)?;
176 let path = entry.path();
177
178 if path.extension().and_then(|s| s.to_str()) == Some("toml") {
179 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
180 if let Ok(identity) = self.get(name) {
181 identities.push(identity);
182 }
183 }
184 }
185 }
186
187 Ok(identities)
188 }
189
190 pub fn set_default(&self, name: &str) -> Result<(), StorageError> {
192 let target_path = self.dir.join(format!("{}.toml", name));
193 if !target_path.exists() {
194 return Err(StorageError::Io(std::io::Error::new(
195 std::io::ErrorKind::NotFound,
196 format!("Identity '{}' not found", name),
197 )));
198 }
199
200 let default_path = self.dir.join("default");
201 if default_path.exists() {
202 fs::remove_file(&default_path).map_err(StorageError::Io)?;
203 }
204
205 #[cfg(unix)]
206 std::os::unix::fs::symlink(target_path, &default_path).map_err(StorageError::Io)?;
207
208 #[cfg(windows)]
209 std::os::windows::fs::symlink_file(target_path, &default_path).map_err(StorageError::Io)?;
210
211 #[cfg(not(any(unix, windows)))]
212 std::fs::copy(target_path, &default_path).map_err(StorageError::Io)?;
213
214 Ok(())
215 }
216
217 pub fn get_default(&self) -> Result<Identity, StorageError> {
219 let default_path = self.dir.join("default");
220 if !default_path.exists() {
221 return Err(StorageError::Io(std::io::Error::new(
222 std::io::ErrorKind::NotFound,
223 "No default identity set",
224 )));
225 }
226
227 let target_path = fs::read_link(&default_path).map_err(StorageError::Io)?;
228 let name = target_path
229 .file_stem()
230 .and_then(|s| s.to_str())
231 .unwrap_or("unknown");
232
233 self.get(name)
234 }
235
236 pub fn load_signing_key(&self, name: &str) -> Result<SigningKey, StorageError> {
248 let path = self.dir.join(format!("{}.toml", name));
249 self.load_key(&path)
250 }
251
252 fn ensure_name_valid(&self, name: &str) -> Result<(), StorageError> {
255 if name.is_empty() || name == "default" || name.contains('/') || name.contains('\\') {
256 return Err(StorageError::Io(std::io::Error::new(
257 std::io::ErrorKind::InvalidInput,
258 "Invalid identity name",
259 )));
260 }
261
262 let path = self.dir.join(format!("{}.toml", name));
263 if path.exists() {
264 return Err(StorageError::Io(std::io::Error::new(
265 std::io::ErrorKind::AlreadyExists,
266 format!("Identity '{}' already exists", name),
267 )));
268 }
269 Ok(())
270 }
271
272 fn save_key(&self, name: &str, signing_key: &SigningKey) -> Result<Identity, StorageError> {
273 let path = self.dir.join(format!("{}.toml", name));
274
275 let secret_str = stellar_strkey::Unredacted(&stellar_strkey::ed25519::PrivateKey(
276 signing_key.to_bytes(),
277 ))
278 .to_string()
279 .as_str()
280 .to_string();
281
282 let toml_content = format!(
283 "# Soroban Identity: {}\nsecret_key = \"{}\"\n",
284 name, secret_str
285 );
286
287 let mut opts = OpenOptions::new();
289 opts.write(true).create(true).truncate(true);
290
291 #[cfg(unix)]
292 opts.mode(0o600);
293
294 let mut file = opts.open(&path).map_err(StorageError::Io)?;
295
296 file.write_all(toml_content.as_bytes())
297 .map_err(StorageError::Io)?;
298
299 let pub_key_str = Strkey::PublicKeyEd25519(stellar_strkey::ed25519::PublicKey(
300 signing_key.verifying_key().to_bytes(),
301 ))
302 .to_string()
303 .as_str()
304 .to_string();
305
306 Ok(Identity {
307 name: name.to_string(),
308 public_key: pub_key_str,
309 })
310 }
311
312 fn load_key(&self, path: &Path) -> Result<SigningKey, StorageError> {
313 let content = fs::read_to_string(path).map_err(StorageError::Io)?;
314
315 let mut secret = None;
316 for line in content.lines() {
317 let line = line.trim();
318 if line.starts_with("secret_key") {
319 let parts: Vec<&str> = line.split('=').collect();
320 if parts.len() == 2 {
321 secret = Some(parts[1].trim().trim_matches('"').to_string());
322 }
323 }
324 }
325
326 let secret = secret.ok_or_else(|| {
327 StorageError::Io(std::io::Error::new(
328 std::io::ErrorKind::InvalidData,
329 "secret_key not found in identity file",
330 ))
331 })?;
332
333 let key = stellar_strkey::ed25519::PrivateKey::from_string(&secret).map_err(|e| {
334 StorageError::Io(std::io::Error::new(
335 std::io::ErrorKind::InvalidData,
336 format!("Invalid stored StrKey: {}", e),
337 ))
338 })?;
339
340 Ok(SigningKey::from_bytes(&key.0))
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use tempfile::tempdir;
348
349 #[test]
350 fn test_generate_and_get() {
351 let dir = tempdir().unwrap();
352 let store = IdentityStore::with_dir(dir.path()).unwrap();
353
354 let identity = store.generate("alice").unwrap();
355 assert_eq!(identity.name, "alice");
356 assert!(identity.public_key.starts_with('G'));
357
358 let loaded = store.get("alice").unwrap();
359 assert_eq!(loaded.name, "alice");
360 assert_eq!(loaded.public_key, identity.public_key);
361 }
362
363 #[test]
364 fn test_import_invalid_key() {
365 let dir = tempdir().unwrap();
366 let store = IdentityStore::with_dir(dir.path()).unwrap();
367
368 assert!(store.import("bob", "not-a-key").is_err());
369 assert!(store
370 .import(
371 "bob",
372 "GBZXLHQZGOWBZY6W3U4Z7GZGGXYVQBZWYM3XEQZ7W5Z4QXYZ5Z3XYY"
373 )
374 .is_err()); }
376
377 #[test]
378 fn test_list_and_remove() {
379 let dir = tempdir().unwrap();
380 let store = IdentityStore::with_dir(dir.path()).unwrap();
381
382 store.generate("alice").unwrap();
383 store.generate("bob").unwrap();
384
385 let mut list = store.list().unwrap();
386 list.sort_by(|a, b| a.name.cmp(&b.name));
387
388 assert_eq!(list.len(), 2);
389 assert_eq!(list[0].name, "alice");
390 assert_eq!(list[1].name, "bob");
391
392 store.remove("alice").unwrap();
393 let list2 = store.list().unwrap();
394 assert_eq!(list2.len(), 1);
395 assert_eq!(list2[0].name, "bob");
396 }
397
398 #[test]
399 fn test_remove_missing_identity_errors() {
400 let dir = tempdir().unwrap();
401 let store = IdentityStore::with_dir(dir.path()).unwrap();
402
403 match store.remove("ghost") {
404 Err(StorageError::NotFound(msg)) => assert_eq!(msg, "Identity 'ghost' not found"),
405 other => panic!("expected NotFound, got {other:?}"),
406 }
407 }
408
409 #[test]
410 fn test_remove_twice_errors_the_second_time() {
411 let dir = tempdir().unwrap();
412 let store = IdentityStore::with_dir(dir.path()).unwrap();
413 store.generate("alice").unwrap();
414
415 store.remove("alice").unwrap();
416 assert!(matches!(
417 store.remove("alice"),
418 Err(StorageError::NotFound(_))
419 ));
420 }
421
422 #[test]
423 fn test_default_identity() {
424 let dir = tempdir().unwrap();
425 let store = IdentityStore::with_dir(dir.path()).unwrap();
426
427 let alice = store.generate("alice").unwrap();
428
429 assert!(store.get_default().is_err());
430
431 store.set_default("alice").unwrap();
432 let def = store.get_default().unwrap();
433
434 assert_eq!(def.name, "alice");
435 assert_eq!(def.public_key, alice.public_key);
436 }
437
438 #[test]
439 fn test_resolve_signing_identity_honors_default_sentinel() {
440 let dir = tempdir().unwrap();
441 let store = IdentityStore::with_dir(dir.path()).unwrap();
442 let alice = store.generate("alice").unwrap();
443
444 match store.resolve_signing_identity(DEFAULT_IDENTITY_NAME) {
447 Err(StorageError::NoDefaultIdentity) => {}
448 other => panic!("expected NoDefaultIdentity, got {other:?}"),
449 }
450
451 store.set_default("alice").unwrap();
452 let resolved = store
453 .resolve_signing_identity(DEFAULT_IDENTITY_NAME)
454 .expect("sentinel resolves once a default is set");
455
456 assert_eq!(resolved.name, "alice");
457 assert_eq!(resolved.public_key, alice.public_key);
458 }
459
460 #[test]
461 fn test_resolve_signing_identity_explicit_names_unchanged() {
462 let dir = tempdir().unwrap();
463 let store = IdentityStore::with_dir(dir.path()).unwrap();
464 let alice = store.generate("alice").unwrap();
465 store.generate("bob").unwrap();
466
467 let resolved = store.resolve_signing_identity("alice").unwrap();
470 assert_eq!(resolved.name, "alice");
471 assert_eq!(resolved.public_key, alice.public_key);
472
473 store.set_default("bob").unwrap();
474 let still_alice = store.resolve_signing_identity("alice").unwrap();
475 assert_eq!(still_alice.name, "alice");
476
477 let err = store.resolve_signing_identity("ghost").unwrap_err();
479 assert!(
480 err.to_string().contains("Identity 'ghost' not found"),
481 "unexpected error: {err}"
482 );
483 }
484
485 #[cfg(windows)]
495 #[test]
496 fn test_new_resolves_windows_config_path() {
497 let store = IdentityStore::new().expect("IdentityStore::new() must succeed on Windows");
498
499 let path_str = store.dir.to_string_lossy().to_lowercase();
501 assert!(
502 path_str.contains("sorobandevkit"),
503 "Windows config path should contain the 'SorobanDevKit' namespace, got: {}",
504 store.dir.display()
505 );
506 assert!(
507 path_str.contains("sdkt"),
508 "Windows config path should contain the 'sdkt' app name, got: {}",
509 store.dir.display()
510 );
511 }
512
513 #[cfg(windows)]
520 #[test]
521 fn test_default_identity_windows_symlink() {
522 let dir = tempdir().unwrap();
523 let store = IdentityStore::with_dir(dir.path()).unwrap();
524
525 let alice = store.generate("alice").unwrap();
527 assert_eq!(alice.name, "alice");
528 assert!(alice.public_key.starts_with('G'));
529
530 assert!(
532 store.get_default().is_err(),
533 "no default should be set initially"
534 );
535
536 store
538 .set_default("alice")
539 .expect("set_default must succeed on Windows");
540
541 let def = store
543 .get_default()
544 .expect("get_default must succeed after set_default on Windows");
545
546 assert_eq!(def.name, "alice");
547 assert_eq!(def.public_key, alice.public_key);
548 }
549}