use crate::error::StorageError;
use directories::ProjectDirs;
use ed25519_dalek::SigningKey;
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use stellar_strkey::Strkey;
pub const DEFAULT_IDENTITY_NAME: &str = "default";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Identity {
pub name: String,
pub public_key: String,
}
pub struct IdentityStore {
dir: PathBuf,
}
impl IdentityStore {
pub fn new() -> Result<Self, StorageError> {
if let Ok(dir) = std::env::var("SDKT_IDENTITY_DIR") {
if !dir.is_empty() {
return Self::with_dir(dir);
}
}
let proj_dirs = ProjectDirs::from("com", "SorobanDevKit", "sdkt").ok_or_else(|| {
StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No valid home directory found",
))
})?;
Self::with_dir(proj_dirs.config_dir().join("identities"))
}
pub fn with_dir<P: AsRef<Path>>(dir: P) -> Result<Self, StorageError> {
let dir = dir.as_ref().to_path_buf();
if !dir.exists() {
fs::create_dir_all(&dir).map_err(StorageError::Io)?;
}
Ok(Self { dir })
}
pub fn generate(&self, name: &str) -> Result<Identity, StorageError> {
self.ensure_name_valid(name)?;
let mut secret = [0u8; 32];
getrandom::fill(&mut secret)
.map_err(|e| StorageError::Io(std::io::Error::other(e.to_string())))?;
let signing_key = SigningKey::from_bytes(&secret);
secret.fill(0);
self.save_key(name, &signing_key)
}
pub fn import(&self, name: &str, secret_key: &str) -> Result<Identity, StorageError> {
self.ensure_name_valid(name)?;
let key = stellar_strkey::ed25519::PrivateKey::from_string(secret_key).map_err(|e| {
StorageError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Invalid StrKey: {}", e),
))
})?;
let signing_key = SigningKey::from_bytes(&key.0);
self.save_key(name, &signing_key)
}
pub fn get(&self, name: &str) -> Result<Identity, StorageError> {
let path = self.dir.join(format!("{}.toml", name));
if !path.exists() {
return Err(StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Identity '{}' not found", name),
)));
}
let signing_key = self.load_key(&path)?;
Ok(Identity {
name: name.to_string(),
public_key: Strkey::PublicKeyEd25519(stellar_strkey::ed25519::PublicKey(
signing_key.verifying_key().to_bytes(),
))
.to_string()
.as_str()
.to_string(),
})
}
pub fn resolve_signing_identity(&self, name: &str) -> Result<Identity, StorageError> {
if name == DEFAULT_IDENTITY_NAME {
return self
.get_default()
.map_err(|_| StorageError::NoDefaultIdentity);
}
self.get(name)
}
pub fn remove(&self, name: &str) -> Result<(), StorageError> {
let path = self.dir.join(format!("{}.toml", name));
if !path.exists() {
return Err(StorageError::NotFound(format!(
"Identity '{}' not found",
name
)));
}
fs::remove_file(path).map_err(StorageError::Io)?;
let default_path = self.dir.join("default");
if default_path.exists() {
if let Ok(target) = fs::read_link(&default_path) {
if target.file_stem().and_then(|s| s.to_str()) == Some(name) {
let _ = fs::remove_file(default_path);
}
}
}
Ok(())
}
pub fn list(&self) -> Result<Vec<Identity>, StorageError> {
let mut identities = Vec::new();
for entry in fs::read_dir(&self.dir).map_err(StorageError::Io)? {
let entry = entry.map_err(StorageError::Io)?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("toml") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
if let Ok(identity) = self.get(name) {
identities.push(identity);
}
}
}
}
Ok(identities)
}
pub fn set_default(&self, name: &str) -> Result<(), StorageError> {
let target_path = self.dir.join(format!("{}.toml", name));
if !target_path.exists() {
return Err(StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Identity '{}' not found", name),
)));
}
let default_path = self.dir.join("default");
if default_path.exists() {
fs::remove_file(&default_path).map_err(StorageError::Io)?;
}
#[cfg(unix)]
std::os::unix::fs::symlink(target_path, &default_path).map_err(StorageError::Io)?;
#[cfg(windows)]
std::os::windows::fs::symlink_file(target_path, &default_path).map_err(StorageError::Io)?;
#[cfg(not(any(unix, windows)))]
std::fs::copy(target_path, &default_path).map_err(StorageError::Io)?;
Ok(())
}
pub fn get_default(&self) -> Result<Identity, StorageError> {
let default_path = self.dir.join("default");
if !default_path.exists() {
return Err(StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No default identity set",
)));
}
let target_path = fs::read_link(&default_path).map_err(StorageError::Io)?;
let name = target_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
self.get(name)
}
pub fn load_signing_key(&self, name: &str) -> Result<SigningKey, StorageError> {
let path = self.dir.join(format!("{}.toml", name));
self.load_key(&path)
}
fn ensure_name_valid(&self, name: &str) -> Result<(), StorageError> {
if name.is_empty() || name == "default" || name.contains('/') || name.contains('\\') {
return Err(StorageError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Invalid identity name",
)));
}
let path = self.dir.join(format!("{}.toml", name));
if path.exists() {
return Err(StorageError::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("Identity '{}' already exists", name),
)));
}
Ok(())
}
fn save_key(&self, name: &str, signing_key: &SigningKey) -> Result<Identity, StorageError> {
let path = self.dir.join(format!("{}.toml", name));
let secret_str = stellar_strkey::Unredacted(&stellar_strkey::ed25519::PrivateKey(
signing_key.to_bytes(),
))
.to_string()
.as_str()
.to_string();
let toml_content = format!(
"# Soroban Identity: {}\nsecret_key = \"{}\"\n",
name, secret_str
);
let mut opts = OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
opts.mode(0o600);
let mut file = opts.open(&path).map_err(StorageError::Io)?;
file.write_all(toml_content.as_bytes())
.map_err(StorageError::Io)?;
let pub_key_str = Strkey::PublicKeyEd25519(stellar_strkey::ed25519::PublicKey(
signing_key.verifying_key().to_bytes(),
))
.to_string()
.as_str()
.to_string();
Ok(Identity {
name: name.to_string(),
public_key: pub_key_str,
})
}
fn load_key(&self, path: &Path) -> Result<SigningKey, StorageError> {
let content = fs::read_to_string(path).map_err(StorageError::Io)?;
let mut secret = None;
for line in content.lines() {
let line = line.trim();
if line.starts_with("secret_key") {
let parts: Vec<&str> = line.split('=').collect();
if parts.len() == 2 {
secret = Some(parts[1].trim().trim_matches('"').to_string());
}
}
}
let secret = secret.ok_or_else(|| {
StorageError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"secret_key not found in identity file",
))
})?;
let key = stellar_strkey::ed25519::PrivateKey::from_string(&secret).map_err(|e| {
StorageError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Invalid stored StrKey: {}", e),
))
})?;
Ok(SigningKey::from_bytes(&key.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_generate_and_get() {
let dir = tempdir().unwrap();
let store = IdentityStore::with_dir(dir.path()).unwrap();
let identity = store.generate("alice").unwrap();
assert_eq!(identity.name, "alice");
assert!(identity.public_key.starts_with('G'));
let loaded = store.get("alice").unwrap();
assert_eq!(loaded.name, "alice");
assert_eq!(loaded.public_key, identity.public_key);
}
#[test]
fn test_import_invalid_key() {
let dir = tempdir().unwrap();
let store = IdentityStore::with_dir(dir.path()).unwrap();
assert!(store.import("bob", "not-a-key").is_err());
assert!(store
.import(
"bob",
"GBZXLHQZGOWBZY6W3U4Z7GZGGXYVQBZWYM3XEQZ7W5Z4QXYZ5Z3XYY"
)
.is_err()); }
#[test]
fn test_list_and_remove() {
let dir = tempdir().unwrap();
let store = IdentityStore::with_dir(dir.path()).unwrap();
store.generate("alice").unwrap();
store.generate("bob").unwrap();
let mut list = store.list().unwrap();
list.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "alice");
assert_eq!(list[1].name, "bob");
store.remove("alice").unwrap();
let list2 = store.list().unwrap();
assert_eq!(list2.len(), 1);
assert_eq!(list2[0].name, "bob");
}
#[test]
fn test_remove_missing_identity_errors() {
let dir = tempdir().unwrap();
let store = IdentityStore::with_dir(dir.path()).unwrap();
match store.remove("ghost") {
Err(StorageError::NotFound(msg)) => assert_eq!(msg, "Identity 'ghost' not found"),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn test_remove_twice_errors_the_second_time() {
let dir = tempdir().unwrap();
let store = IdentityStore::with_dir(dir.path()).unwrap();
store.generate("alice").unwrap();
store.remove("alice").unwrap();
assert!(matches!(
store.remove("alice"),
Err(StorageError::NotFound(_))
));
}
#[test]
fn test_default_identity() {
let dir = tempdir().unwrap();
let store = IdentityStore::with_dir(dir.path()).unwrap();
let alice = store.generate("alice").unwrap();
assert!(store.get_default().is_err());
store.set_default("alice").unwrap();
let def = store.get_default().unwrap();
assert_eq!(def.name, "alice");
assert_eq!(def.public_key, alice.public_key);
}
#[test]
fn test_resolve_signing_identity_honors_default_sentinel() {
let dir = tempdir().unwrap();
let store = IdentityStore::with_dir(dir.path()).unwrap();
let alice = store.generate("alice").unwrap();
match store.resolve_signing_identity(DEFAULT_IDENTITY_NAME) {
Err(StorageError::NoDefaultIdentity) => {}
other => panic!("expected NoDefaultIdentity, got {other:?}"),
}
store.set_default("alice").unwrap();
let resolved = store
.resolve_signing_identity(DEFAULT_IDENTITY_NAME)
.expect("sentinel resolves once a default is set");
assert_eq!(resolved.name, "alice");
assert_eq!(resolved.public_key, alice.public_key);
}
#[test]
fn test_resolve_signing_identity_explicit_names_unchanged() {
let dir = tempdir().unwrap();
let store = IdentityStore::with_dir(dir.path()).unwrap();
let alice = store.generate("alice").unwrap();
store.generate("bob").unwrap();
let resolved = store.resolve_signing_identity("alice").unwrap();
assert_eq!(resolved.name, "alice");
assert_eq!(resolved.public_key, alice.public_key);
store.set_default("bob").unwrap();
let still_alice = store.resolve_signing_identity("alice").unwrap();
assert_eq!(still_alice.name, "alice");
let err = store.resolve_signing_identity("ghost").unwrap_err();
assert!(
err.to_string().contains("Identity 'ghost' not found"),
"unexpected error: {err}"
);
}
#[cfg(windows)]
#[test]
fn test_new_resolves_windows_config_path() {
let store = IdentityStore::new().expect("IdentityStore::new() must succeed on Windows");
let path_str = store.dir.to_string_lossy().to_lowercase();
assert!(
path_str.contains("sorobandevkit"),
"Windows config path should contain the 'SorobanDevKit' namespace, got: {}",
store.dir.display()
);
assert!(
path_str.contains("sdkt"),
"Windows config path should contain the 'sdkt' app name, got: {}",
store.dir.display()
);
}
#[cfg(windows)]
#[test]
fn test_default_identity_windows_symlink() {
let dir = tempdir().unwrap();
let store = IdentityStore::with_dir(dir.path()).unwrap();
let alice = store.generate("alice").unwrap();
assert_eq!(alice.name, "alice");
assert!(alice.public_key.starts_with('G'));
assert!(
store.get_default().is_err(),
"no default should be set initially"
);
store
.set_default("alice")
.expect("set_default must succeed on Windows");
let def = store
.get_default()
.expect("get_default must succeed after set_default on Windows");
assert_eq!(def.name, "alice");
assert_eq!(def.public_key, alice.public_key);
}
}