use serde::{Deserialize, Serialize};
use std::str::FromStr;
use sep5::SeedPhrase;
use stellar_strkey::ed25519::{PrivateKey, PublicKey};
use crate::{
print::Print,
signer::{
self, ledger::LedgerEntry, secure_store, LocalKey, SecureStoreEntry, Signer, SignerKind,
},
utils,
};
use super::key::Key;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
Secret(#[from] stellar_strkey::DecodeError),
#[error(transparent)]
SeedPhrase(#[from] sep5::error::Error),
#[error(transparent)]
Ed25519(#[from] ed25519_dalek::SignatureError),
#[error("cannot parse secret (S) or seed phrase (12 or 24 word)")]
InvalidSecretOrSeedPhrase,
#[error(transparent)]
Signer(#[from] signer::Error),
#[error("Ledger does not reveal secret key")]
LedgerDoesNotRevealSecretKey,
#[error(transparent)]
SecureStore(#[from] secure_store::Error),
#[error("Secure Store does not reveal secret key")]
SecureStoreDoesNotRevealSecretKey,
#[error(transparent)]
Ledger(#[from] signer::ledger::Error),
#[error("--hd-path is fixed at the time a Ledger identity is added; pass `--ledger --hd-path N` to inspect another path on the device")]
LedgerHdPathFixed,
}
#[derive(Debug, clap::Args, Clone)]
#[group(skip)]
pub struct Args {
#[arg(long)]
pub secret_key: bool,
#[arg(long)]
pub seed_phrase: bool,
#[arg(long)]
pub secure_store: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum Secret {
SecretKey {
secret_key: String,
},
SeedPhrase {
seed_phrase: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
hd_path: Option<u32>,
},
Ledger {
hardware: HardwareKind,
public_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
hd_path: Option<u32>,
},
SecureStore {
entry_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
hd_path: Option<u32>,
},
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum HardwareKind {
Ledger,
}
impl FromStr for Secret {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if PrivateKey::from_string(s).is_ok() {
Ok(Secret::SecretKey {
secret_key: s.to_string(),
})
} else if sep5::SeedPhrase::from_str(s).is_ok() {
Ok(Secret::SeedPhrase {
seed_phrase: s.to_string(),
hd_path: None,
})
} else if s.starts_with(secure_store::ENTRY_PREFIX) {
Ok(Secret::SecureStore {
entry_name: s.to_string(),
public_key: None,
hd_path: None,
})
} else {
Err(Error::InvalidSecretOrSeedPhrase)
}
}
}
impl From<PrivateKey> for Secret {
fn from(value: PrivateKey) -> Self {
Secret::SecretKey {
secret_key: format!("{value}"),
}
}
}
impl From<Secret> for Key {
fn from(value: Secret) -> Self {
Key::Secret(value)
}
}
impl From<SeedPhrase> for Secret {
fn from(value: SeedPhrase) -> Self {
Secret::SeedPhrase {
seed_phrase: value.seed_phrase.into_phrase(),
hd_path: None,
}
}
}
impl Secret {
pub fn private_key(&self, index: Option<u32>) -> Result<PrivateKey, Error> {
Ok(match self {
Secret::SecretKey { secret_key } => PrivateKey::from_string(secret_key)?,
Secret::SeedPhrase {
seed_phrase,
hd_path,
} => PrivateKey::from_payload(
&sep5::SeedPhrase::from_str(seed_phrase)?
.from_path_index(index.or(*hd_path).unwrap_or_default() as usize, None)?
.private()
.0,
)?,
Secret::Ledger { .. } => return Err(Error::LedgerDoesNotRevealSecretKey),
Secret::SecureStore { .. } => {
return Err(Error::SecureStoreDoesNotRevealSecretKey);
}
})
}
pub fn public_key(&self, index: Option<u32>) -> Result<PublicKey, Error> {
match self {
Secret::SecureStore {
entry_name,
public_key,
hd_path,
} => {
let effective = index.or(*hd_path);
if let Some(cached) = cached_public_key(public_key.as_deref(), *hd_path, effective)
{
return Ok(cached);
}
Ok(secure_store::get_public_key(entry_name, effective)?)
}
Secret::Ledger { public_key, .. } => {
if index.is_some() {
return Err(Error::LedgerHdPathFixed);
}
Ok(PublicKey::from_string(public_key)?)
}
_ => {
let key = self.key_pair(index)?;
Ok(stellar_strkey::ed25519::PublicKey::from_payload(
key.verifying_key().as_bytes(),
)?)
}
}
}
pub fn signer(&self, hd_path: Option<u32>, print: Print) -> Result<Signer, Error> {
let kind = match self {
Secret::SecretKey { .. } | Secret::SeedPhrase { .. } => {
let key = self.key_pair(hd_path)?;
SignerKind::Local(LocalKey { key })
}
Secret::Ledger {
hardware: HardwareKind::Ledger,
public_key,
hd_path: cached_hd_path,
} => {
if hd_path.is_some() {
return Err(Error::LedgerHdPathFixed);
}
SignerKind::Ledger(LedgerEntry {
hd_path: cached_hd_path.unwrap_or_default(),
public_key: Some(PublicKey::from_string(public_key)?),
})
}
Secret::SecureStore {
entry_name,
public_key,
hd_path: cached_hd_path,
} => {
let effective = hd_path.or(*cached_hd_path);
let cached_public_key =
cached_public_key(public_key.as_deref(), *cached_hd_path, effective);
SignerKind::SecureStore(SecureStoreEntry {
name: entry_name.clone(),
hd_path: effective,
public_key: cached_public_key,
})
}
};
Ok(Signer { kind, print })
}
pub fn key_pair(&self, index: Option<u32>) -> Result<ed25519_dalek::SigningKey, Error> {
Ok(utils::into_signing_key(&self.private_key(index)?))
}
pub fn from_seed(seed: Option<&str>) -> Result<Self, Error> {
Ok(seed_phrase_from_seed(seed)?.into())
}
}
fn cached_public_key(
cached: Option<&str>,
cached_hd_path: Option<u32>,
requested_hd_path: Option<u32>,
) -> Option<PublicKey> {
if cached_hd_path.unwrap_or_default() != requested_hd_path.unwrap_or_default() {
return None;
}
PublicKey::from_string(cached?).ok()
}
pub fn seed_phrase_from_seed(seed: Option<&str>) -> Result<SeedPhrase, Error> {
Ok(if let Some(seed) = seed.map(str::as_bytes) {
sep5::SeedPhrase::from_entropy(seed)?
} else {
sep5::SeedPhrase::random(sep5::MnemonicType::Words24)?
})
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
const TEST_SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH";
const TEST_SEED_PHRASE: &str =
"depth decade power loud smile spatial sign movie judge february rate broccoli";
#[test]
fn test_from_str_for_secret_key() {
let secret = Secret::from_str(TEST_SECRET_KEY).unwrap();
let public_key = secret.public_key(None).unwrap();
let private_key = secret.private_key(None).unwrap();
assert!(matches!(secret, Secret::SecretKey { .. }));
assert_eq!(public_key.to_string(), TEST_PUBLIC_KEY);
assert_eq!(private_key.to_string(), TEST_SECRET_KEY);
}
#[test]
fn test_secret_from_seed_phrase() {
let secret = Secret::from_str(TEST_SEED_PHRASE).unwrap();
let public_key = secret.public_key(None).unwrap();
let private_key = secret.private_key(None).unwrap();
assert!(matches!(secret, Secret::SeedPhrase { .. }));
assert_eq!(public_key.to_string(), TEST_PUBLIC_KEY);
assert_eq!(private_key.to_string(), TEST_SECRET_KEY);
}
#[test]
fn test_secret_from_secure_store() {
let secret = Secret::from_str("secure_store:org.stellar.cli-alice").unwrap();
assert!(matches!(secret, Secret::SecureStore { .. }));
let private_key_result = secret.private_key(None);
assert!(private_key_result.is_err());
assert!(matches!(
private_key_result.unwrap_err(),
Error::SecureStoreDoesNotRevealSecretKey
));
}
#[test]
fn test_secret_from_invalid_string() {
let secret = Secret::from_str("invalid");
assert!(secret.is_err());
}
#[test]
fn test_secure_store_toml_round_trip_with_cache() {
let secret = Secret::SecureStore {
entry_name: "secure_store:org.stellar.cli-alice".to_string(),
public_key: Some(TEST_PUBLIC_KEY.to_string()),
hd_path: None,
};
let serialized = toml::to_string(&secret).unwrap();
assert!(
serialized.contains("entry_name"),
"expected entry_name field in TOML, got: {serialized}"
);
assert!(
serialized.contains("public_key"),
"expected public_key field in TOML, got: {serialized}"
);
let parsed: Secret = toml::from_str(&serialized).unwrap();
assert_eq!(secret, parsed);
}
#[test]
fn test_secure_store_legacy_toml_parses_with_none_cache() {
let toml_str = "entry_name = \"secure_store:org.stellar.cli-alice\"\n";
let secret: Secret = toml::from_str(toml_str).unwrap();
match secret {
Secret::SecureStore {
entry_name,
public_key,
hd_path,
} => {
assert_eq!(entry_name, "secure_store:org.stellar.cli-alice");
assert_eq!(public_key, None);
assert_eq!(hd_path, None);
}
other => panic!("expected SecureStore variant, got {other:?}"),
}
}
#[test]
fn test_secure_store_public_key_uses_cache_without_keychain_access() {
let secret = Secret::SecureStore {
entry_name: "secure_store:org.stellar.cli-no-such-entry".to_string(),
public_key: Some(TEST_PUBLIC_KEY.to_string()),
hd_path: None,
};
let pk = secret.public_key(None).unwrap();
assert_eq!(pk.to_string(), TEST_PUBLIC_KEY);
}
#[test]
fn test_secure_store_public_key_falls_back_to_persisted_hd_path() {
let secret = Secret::SecureStore {
entry_name: "secure_store:org.stellar.cli-no-such-entry".to_string(),
public_key: Some(TEST_PUBLIC_KEY.to_string()),
hd_path: Some(5),
};
let pk = secret.public_key(None).unwrap();
assert_eq!(pk.to_string(), TEST_PUBLIC_KEY);
}
#[test]
fn test_cached_public_key_treats_none_and_zero_as_equal() {
assert!(cached_public_key(Some(TEST_PUBLIC_KEY), None, Some(0)).is_some());
assert!(cached_public_key(Some(TEST_PUBLIC_KEY), Some(0), None).is_some());
assert!(cached_public_key(Some(TEST_PUBLIC_KEY), None, None).is_some());
assert!(cached_public_key(Some(TEST_PUBLIC_KEY), Some(0), Some(0)).is_some());
assert!(cached_public_key(Some(TEST_PUBLIC_KEY), None, Some(1)).is_none());
assert!(cached_public_key(Some(TEST_PUBLIC_KEY), Some(1), None).is_none());
}
#[test]
fn test_cached_public_key_treats_corrupt_value_as_miss() {
assert!(cached_public_key(Some("not-a-public-key"), None, None).is_none());
assert!(cached_public_key(Some(""), None, None).is_none());
}
#[test]
fn test_seed_phrase_toml_round_trip_with_hd_path() {
let secret = Secret::SeedPhrase {
seed_phrase: TEST_SEED_PHRASE.to_string(),
hd_path: Some(5),
};
let serialized = toml::to_string(&secret).unwrap();
assert!(
serialized.contains("hd_path"),
"expected hd_path field in TOML, got: {serialized}"
);
let parsed: Secret = toml::from_str(&serialized).unwrap();
assert_eq!(secret, parsed);
}
#[test]
fn test_seed_phrase_legacy_toml_parses_with_none_hd_path() {
let toml_str = format!("seed_phrase = \"{TEST_SEED_PHRASE}\"\n");
let secret: Secret = toml::from_str(&toml_str).unwrap();
match secret {
Secret::SeedPhrase {
seed_phrase,
hd_path,
} => {
assert_eq!(seed_phrase, TEST_SEED_PHRASE);
assert_eq!(hd_path, None);
}
other => panic!("expected SeedPhrase variant, got {other:?}"),
}
}
#[test]
fn test_seed_phrase_uses_persisted_hd_path_when_caller_passes_none() {
let secret = Secret::SeedPhrase {
seed_phrase: TEST_SEED_PHRASE.to_string(),
hd_path: Some(1),
};
let pk_at_0 = secret.public_key(Some(0)).unwrap();
let pk_default = secret.public_key(None).unwrap();
assert_ne!(pk_at_0.to_string(), pk_default.to_string());
}
#[test]
fn test_seed_phrase_caller_hd_path_overrides_persisted() {
let secret = Secret::SeedPhrase {
seed_phrase: TEST_SEED_PHRASE.to_string(),
hd_path: Some(1),
};
let pk = secret.public_key(Some(0)).unwrap();
let sk = secret.private_key(Some(0)).unwrap();
assert_eq!(pk.to_string(), TEST_PUBLIC_KEY);
assert_eq!(sk.to_string(), TEST_SECRET_KEY);
}
#[test]
fn test_ledger_toml_round_trip_with_hd_path() {
let secret = Secret::Ledger {
hardware: HardwareKind::Ledger,
public_key: TEST_PUBLIC_KEY.to_string(),
hd_path: Some(5),
};
let serialized = toml::to_string(&secret).unwrap();
assert!(
serialized.contains("hardware = \"ledger\""),
"expected `hardware = \"ledger\"` tag in TOML, got: {serialized}"
);
assert!(
serialized.contains("public_key"),
"expected public_key field in TOML, got: {serialized}"
);
assert!(
serialized.contains("hd_path"),
"expected hd_path field in TOML, got: {serialized}"
);
let parsed: Secret = toml::from_str(&serialized).unwrap();
assert_eq!(secret, parsed);
}
#[test]
fn test_ledger_toml_omits_hd_path_when_none() {
let secret = Secret::Ledger {
hardware: HardwareKind::Ledger,
public_key: TEST_PUBLIC_KEY.to_string(),
hd_path: None,
};
let serialized = toml::to_string(&secret).unwrap();
assert!(
!serialized.contains("hd_path"),
"expected no hd_path field in TOML when None, got: {serialized}"
);
let parsed: Secret = toml::from_str(&serialized).unwrap();
assert_eq!(secret, parsed);
}
#[test]
fn test_ledger_public_key_returns_cached_without_device() {
let secret = Secret::Ledger {
hardware: HardwareKind::Ledger,
public_key: TEST_PUBLIC_KEY.to_string(),
hd_path: Some(5),
};
let pk = secret.public_key(None).unwrap();
assert_eq!(pk.to_string(), TEST_PUBLIC_KEY);
}
#[test]
fn test_ledger_public_key_rejects_caller_hd_path() {
let secret = Secret::Ledger {
hardware: HardwareKind::Ledger,
public_key: TEST_PUBLIC_KEY.to_string(),
hd_path: Some(5),
};
assert!(matches!(
secret.public_key(Some(5)).unwrap_err(),
Error::LedgerHdPathFixed,
));
assert!(matches!(
secret.public_key(Some(7)).unwrap_err(),
Error::LedgerHdPathFixed,
));
}
#[test]
fn test_ledger_public_key_uses_cached_path_when_caller_passes_none() {
let secret = Secret::Ledger {
hardware: HardwareKind::Ledger,
public_key: TEST_PUBLIC_KEY.to_string(),
hd_path: Some(5),
};
assert_eq!(
secret.public_key(None).unwrap().to_string(),
TEST_PUBLIC_KEY
);
}
#[test]
fn test_ledger_private_key_is_rejected() {
let secret = Secret::Ledger {
hardware: HardwareKind::Ledger,
public_key: TEST_PUBLIC_KEY.to_string(),
hd_path: None,
};
assert!(matches!(
secret.private_key(None).unwrap_err(),
Error::LedgerDoesNotRevealSecretKey,
));
}
#[test]
fn test_ledger_toml_does_not_collide_with_secure_store() {
let toml_str = "entry_name = \"secure_store:org.stellar.cli-alice\"\n\
public_key = \"GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ\"\n";
let secret: Secret = toml::from_str(toml_str).unwrap();
assert!(matches!(secret, Secret::SecureStore { .. }));
}
}