Skip to main content

sdkt_storage/
identity.rs

1//! Identity and Keystore Management.
2//!
3//! Stores `ed25519` keypairs securely using OS-portable config directories.
4//! Keys are stored using strict permissions (`0600`) to prevent unauthorized access.
5
6use 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
17/// Reserved identity name that selects the keystore's configured default.
18///
19/// Every signing command's `--identity` flag defaults to this literal string,
20/// but it can never name a key on disk — the keystore rejects it as an invalid
21/// identity name. It is a sentinel, resolved through
22/// [`IdentityStore::resolve_signing_identity`].
23pub const DEFAULT_IDENTITY_NAME: &str = "default";
24
25/// A local Soroban identity containing a named keypair.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Identity {
28    pub name: String,
29    pub public_key: String,
30}
31
32/// Keystore for managing named Soroban identities.
33pub struct IdentityStore {
34    dir: PathBuf,
35}
36
37impl IdentityStore {
38    /// Initialize the keystore in the default OS-portable config directory (`~/.config/sdkt/identities/`).
39    ///
40    /// If the `SDKT_IDENTITY_DIR` environment variable is set, its value is used instead of the
41    /// OS-default config directory. This provides a cross-platform override (works on macOS and
42    /// Windows, where `XDG_CONFIG_HOME` is ignored by `directories::ProjectDirs`), and is the
43    /// canonical way for tests and CI to isolate the identity store.
44    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    /// Initialize the keystore in a custom directory.
62    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    /// Generate a new random ED25519 identity.
71    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    /// Import an identity from a secret key (S...) string.
84    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    /// Load an identity by name.
99    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    /// Resolve the signing identity named by a CLI `--identity` value.
122    ///
123    /// The signing commands default `--identity` to the reserved literal
124    /// [`DEFAULT_IDENTITY_NAME`] (`"default"`), which can never be a real key.
125    /// When `name` is that sentinel this returns the identity selected by
126    /// [`IdentityStore::set_default`]; for any other `name` it is exactly
127    /// [`IdentityStore::get`], so an explicit `--identity <name>` is unchanged.
128    ///
129    /// # Errors
130    /// Returns [`StorageError::NoDefaultIdentity`] when the sentinel is used and
131    /// no default has been configured, and the [`IdentityStore::get`] error for
132    /// any other unresolvable name.
133    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    /// Delete an identity.
144    ///
145    /// Returns [`StorageError::NotFound`] when no identity with that name
146    /// exists, matching `NetworkStore::remove`, so a mistyped name is reported
147    /// rather than treated as a successful removal.
148    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        // If it was the default identity, clear the default symlink.
159        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    /// List all stored identities.
171    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    /// Set an identity as the default.
191    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    /// Load the default identity.
218    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    /// Load the signing key (`ed25519_dalek::SigningKey`) for a named identity.
237    ///
238    /// This is the keystore integration point used by transaction signing:
239    /// the caller extracts the 32-byte seed via `signing_key.to_bytes()` and
240    /// passes it to `sdkt_xdr::Ed25519Signer::from_seed`. The secret material
241    /// is never exposed as a string; only the in-memory `SigningKey` is
242    /// returned, and only for the duration the caller holds it.
243    ///
244    /// # Errors
245    /// Returns [`StorageError`] if the identity does not exist or its secret
246    /// cannot be loaded.
247    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    // --- Private Helpers ---
253
254    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        // Strict permissions 0600
288        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()); // public key, not secret
375    }
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        // Sentinel with no default configured: an actionable, specific error
445        // (not the opaque "Identity 'default' not found" the literal name gave).
446        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        // An explicit name is looked up verbatim, even when a *different*
468        // identity is the default, and even before any default exists.
469        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        // A missing explicit name keeps the plain `get` error, name included.
478        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    // ---------------------------------------------------------------------------
486    // Windows-specific regression tests
487    // ---------------------------------------------------------------------------
488
489    /// Verifies the production IdentityStore::new() constructor resolves a
490    /// platform-appropriate config directory on Windows.
491    ///
492    /// Read-only against production constructor — does not create identities
493    /// in the real config directory.
494    #[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        // Windows: %APPDATA%\SorobanDevKit\sdkt\identities\
500        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    /// Verifies that set_default/get_default works on Windows using the
514    /// Windows-specific symlink implementation.
515    ///
516    /// This test exercises the existing:
517    /// #[cfg(windows)] std::os::windows::fs::symlink_file
518    /// path which is never exercised on Linux or macOS CI.
519    #[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        // Generate an identity
526        let alice = store.generate("alice").unwrap();
527        assert_eq!(alice.name, "alice");
528        assert!(alice.public_key.starts_with('G'));
529
530        // Verify no default is set initially
531        assert!(
532            store.get_default().is_err(),
533            "no default should be set initially"
534        );
535
536        // Set default — this calls #[cfg(windows)] symlink_file on Windows
537        store
538            .set_default("alice")
539            .expect("set_default must succeed on Windows");
540
541        // Get default — this reads the symlink on Windows
542        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}