Skip to content

Commit 7ad0fe1

Browse files
committed
Persist --hd-path on plain seed-phrase and Secure Store keys.
Plain seed-phrase identities now persist --hd-path and honor it as a default for later derivations. Secure Store identities did the same on disk but ignored the persisted hd_path at derivation time, falling back to index 0 unless the user re-passed --hd-path; Secret::SecureStore now also falls back to the persisted value when the caller passes None, matching the behavior of Secret::SeedPhrase. Caller-supplied indices still take precedence.
1 parent 343069b commit 7ad0fe1

8 files changed

Lines changed: 240 additions & 17 deletions

File tree

FULL_HELP_DOCS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,7 +1138,7 @@ Add a new identity (keypair, ledger, OS specific secure store)
11381138

11391139
- `--public-key <PUBLIC_KEY>` — Add a public key, ed25519, or muxed account, e.g. G1.., M2..
11401140
- `--overwrite` — Overwrite existing identity if it already exists. When combined with --secure-store, also replaces the existing Secure Store entry
1141-
- `--hd-path <HD_PATH>` — When importing a seed phrase into the Secure Store, which `hd_path` to derive the key at
1141+
- `--hd-path <HD_PATH>` — When importing a seed phrase, which `hd_path` to derive the key at. Persisted on the identity (or its Secure Store entry) so later commands derive the same account without re-passing the flag. Not valid with `--public-key` or a raw secret key
11421142

11431143
###### **Options (Global):**
11441144

@@ -1207,7 +1207,7 @@ Generate a new identity using a 24-word seed phrase The seed phrase can be store
12071207

12081208
On Mac this uses Keychain, on Windows it is Secure Store Service, and on \*nix platforms it uses a combination of the kernel keyutils and DBus-based Secret Service.
12091209

1210-
- `--hd-path <HD_PATH>`With `--as-secret` or `--secure-store`, which `hd_path` to derive the key at from the seed phrase
1210+
- `--hd-path <HD_PATH>`Which `hd_path` to derive the key at from the seed phrase. Honored across all storage modes: with `--as-secret` it picks which derived key is stored, with `--secure-store` or plain seed-phrase storage it is persisted on the identity so later commands derive the same account without re-passing the flag
12111211
- `--fund` — Fund generated key pair
12121212

12131213
Default value: `false`

cmd/crates/soroban-test/tests/it/integration/hello_world.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ async fn invoke_contract() {
7171
let dir = sandbox.config_dir();
7272
let seed_phrase = std::fs::read_to_string(dir.join("identity/test.toml")).unwrap();
7373
let s = toml::from_str::<secret::Secret>(&seed_phrase).unwrap();
74-
let secret::Secret::SeedPhrase { seed_phrase } = s else {
74+
let secret::Secret::SeedPhrase { seed_phrase, .. } = s else {
7575
panic!("Expected seed phrase")
7676
};
7777

cmd/crates/soroban-test/tests/it/util.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub fn add_key(dir: &Path, name: &str, kind: SecretKind, data: &str) {
1818
let secret = match kind {
1919
SecretKind::Seed => Secret::SeedPhrase {
2020
seed_phrase: data.to_string(),
21+
hd_path: None,
2122
},
2223
SecretKind::Key => Secret::SecretKey {
2324
secret_key: data.to_string(),

cmd/soroban-cli/src/commands/keys/add.rs

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ pub enum Error {
3939
unset STELLAR_SECRET_KEY or provide a seed phrase instead"
4040
)]
4141
SecureStoreRequiresSeedPhrase,
42+
43+
#[error("--hd-path is not valid with a secret key; secret keys cannot be derived")]
44+
HdPathNotSupportedForSecretKey,
45+
46+
#[error("--hd-path is not valid with --public-key; public keys cannot be derived")]
47+
HdPathNotSupportedForPublicKey,
4248
}
4349

4450
#[derive(Debug, clap::Parser, Clone)]
@@ -62,7 +68,9 @@ pub struct Cmd {
6268
#[arg(long)]
6369
pub overwrite: bool,
6470

65-
/// When importing a seed phrase into the Secure Store, which `hd_path` to derive the key at.
71+
/// When importing a seed phrase, which `hd_path` to derive the key at. Persisted on
72+
/// the identity (or its Secure Store entry) so later commands derive the same account
73+
/// without re-passing the flag. Not valid with `--public-key` or a raw secret key.
6674
#[arg(long)]
6775
pub hd_path: Option<usize>,
6876
}
@@ -71,6 +79,10 @@ impl Cmd {
7179
pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
7280
let print = Print::new(global_args.quiet);
7381

82+
if self.public_key.is_some() && self.hd_path.is_some() {
83+
return Err(Error::HdPathNotSupportedForPublicKey);
84+
}
85+
7486
if self.config_locator.read_identity(&self.name).is_ok() {
7587
if !self.overwrite {
7688
return Err(Error::IdentityAlreadyExists(self.name.to_string()));
@@ -98,7 +110,7 @@ impl Cmd {
98110
return Err(Error::SecureStoreRequiresSeedPhrase);
99111
}
100112
} else if let Ok(secret_key) = std::env::var("STELLAR_SECRET_KEY") {
101-
return Ok(secret_key.parse()?);
113+
return build_secret(&secret_key, self.hd_path);
102114
}
103115

104116
if self.secrets.secure_store {
@@ -123,8 +135,8 @@ impl Cmd {
123135
} else {
124136
let prompt = "Type a secret key or 12/24 word seed phrase:";
125137
let secret_key = read_password(print, prompt)?;
126-
let secret = secret_key.parse()?;
127-
if let Secret::SeedPhrase { seed_phrase } = &secret {
138+
let secret = build_secret(&secret_key, self.hd_path)?;
139+
if let Secret::SeedPhrase { seed_phrase, .. } = &secret {
128140
if seed_phrase.split_whitespace().count() < 24 {
129141
print.warnln("The provided seed phrase lacks sufficient entropy and should be avoided. Using a 24-word seed phrase is a safer option.".to_string());
130142
print.warnln(
@@ -138,6 +150,18 @@ impl Cmd {
138150
}
139151
}
140152

153+
fn build_secret(input: &str, hd_path: Option<usize>) -> Result<Secret, Error> {
154+
let secret: Secret = input.parse()?;
155+
match (secret, hd_path) {
156+
(Secret::SecretKey { .. }, Some(_)) => Err(Error::HdPathNotSupportedForSecretKey),
157+
(Secret::SeedPhrase { seed_phrase, .. }, hd_path) => Ok(Secret::SeedPhrase {
158+
seed_phrase,
159+
hd_path,
160+
}),
161+
(secret, _) => Ok(secret),
162+
}
163+
}
164+
141165
fn read_password(print: &Print, prompt: &str) -> Result<String, Error> {
142166
if std::io::stdin().is_terminal() {
143167
// Interactive: prompt and read from TTY
@@ -157,3 +181,90 @@ fn read_password(print: &Print, prompt: &str) -> Result<String, Error> {
157181
Ok(input)
158182
}
159183
}
184+
185+
#[cfg(test)]
186+
mod tests {
187+
use super::*;
188+
use crate::config::address::KeyName;
189+
190+
const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
191+
const TEST_SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH";
192+
const TEST_SEED_PHRASE: &str =
193+
"depth decade power loud smile spatial sign movie judge february rate broccoli";
194+
195+
#[test]
196+
fn test_build_secret_persists_hd_path_on_seed_phrase() {
197+
let secret = build_secret(TEST_SEED_PHRASE, Some(5)).unwrap();
198+
match secret {
199+
Secret::SeedPhrase {
200+
seed_phrase,
201+
hd_path,
202+
} => {
203+
assert_eq!(seed_phrase, TEST_SEED_PHRASE);
204+
assert_eq!(hd_path, Some(5));
205+
}
206+
other => panic!("expected SeedPhrase variant, got {other:?}"),
207+
}
208+
}
209+
210+
#[test]
211+
fn test_build_secret_seed_phrase_without_hd_path() {
212+
let secret = build_secret(TEST_SEED_PHRASE, None).unwrap();
213+
match secret {
214+
Secret::SeedPhrase { hd_path, .. } => assert_eq!(hd_path, None),
215+
other => panic!("expected SeedPhrase variant, got {other:?}"),
216+
}
217+
}
218+
219+
#[test]
220+
fn test_build_secret_rejects_hd_path_with_secret_key() {
221+
let result = build_secret(TEST_SECRET_KEY, Some(5));
222+
assert!(matches!(result, Err(Error::HdPathNotSupportedForSecretKey)));
223+
}
224+
225+
#[test]
226+
fn test_build_secret_secret_key_without_hd_path() {
227+
let secret = build_secret(TEST_SECRET_KEY, None).unwrap();
228+
assert!(matches!(secret, Secret::SecretKey { .. }));
229+
}
230+
231+
fn cmd_with_public_key(public_key: &str, hd_path: Option<usize>) -> (Cmd, tempfile::TempDir) {
232+
let temp_dir = tempfile::tempdir().unwrap();
233+
let cmd = Cmd {
234+
name: KeyName("test_name".to_string()),
235+
secrets: secret::Args {
236+
secret_key: false,
237+
seed_phrase: false,
238+
secure_store: false,
239+
},
240+
config_locator: locator::Args {
241+
config_dir: Some(temp_dir.path().to_path_buf()),
242+
},
243+
public_key: Some(public_key.to_string()),
244+
overwrite: false,
245+
hd_path,
246+
};
247+
(cmd, temp_dir)
248+
}
249+
250+
#[test]
251+
fn test_run_rejects_hd_path_with_public_key() {
252+
let (cmd, _temp_dir) = cmd_with_public_key(TEST_PUBLIC_KEY, Some(3));
253+
let global_args = global::Args {
254+
quiet: true,
255+
..Default::default()
256+
};
257+
let result = cmd.run(&global_args);
258+
assert!(matches!(result, Err(Error::HdPathNotSupportedForPublicKey)));
259+
}
260+
261+
#[test]
262+
fn test_run_accepts_public_key_without_hd_path() {
263+
let (cmd, _temp_dir) = cmd_with_public_key(TEST_PUBLIC_KEY, None);
264+
let global_args = global::Args {
265+
quiet: true,
266+
..Default::default()
267+
};
268+
assert!(cmd.run(&global_args).is_ok());
269+
}
270+
}

cmd/soroban-cli/src/commands/keys/generate.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ pub struct Cmd {
5050
#[command(flatten)]
5151
pub config_locator: locator::Args,
5252

53-
/// With `--as-secret` or `--secure-store`, which `hd_path` to derive the key at from the seed phrase.
53+
/// Which `hd_path` to derive the key at from the seed phrase. Honored across all
54+
/// storage modes: with `--as-secret` it picks which derived key is stored, with
55+
/// `--secure-store` or plain seed-phrase storage it is persisted on the identity
56+
/// so later commands derive the same account without re-passing the flag.
5457
#[arg(long)]
5558
pub hd_path: Option<usize>,
5659

@@ -127,7 +130,10 @@ impl Cmd {
127130
let secret: Secret = seed_phrase.into();
128131
Ok(secret.private_key(self.hd_path)?.into())
129132
} else {
130-
Ok(seed_phrase.into())
133+
Ok(Secret::SeedPhrase {
134+
seed_phrase: seed_phrase.seed_phrase.into_phrase(),
135+
hd_path: self.hd_path,
136+
})
131137
}
132138
}
133139

@@ -179,6 +185,23 @@ mod tests {
179185
assert!(matches!(identity, Key::Secret(Secret::SeedPhrase { .. })));
180186
}
181187

188+
#[tokio::test]
189+
async fn test_generate_seed_phrase_persists_hd_path() {
190+
let (test_locator, mut cmd) = set_up_test();
191+
cmd.hd_path = Some(7);
192+
let global_args = global_args();
193+
194+
cmd.run(&global_args).await.unwrap();
195+
196+
let identity = test_locator.read_identity("test_name").unwrap();
197+
match identity {
198+
Key::Secret(Secret::SeedPhrase { hd_path, .. }) => {
199+
assert_eq!(hd_path, Some(7));
200+
}
201+
other => panic!("expected SeedPhrase variant, got {other:?}"),
202+
}
203+
}
204+
182205
#[tokio::test]
183206
async fn test_storing_secret_as_a_secret_key() {
184207
let (test_locator, mut cmd) = set_up_test();

cmd/soroban-cli/src/commands/keys/secret.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ impl Cmd {
4949
pub fn seed_phrase(&self) -> Result<String, Error> {
5050
let key = self.locator.read_identity(&self.name)?;
5151

52-
if let Key::Secret(Secret::SeedPhrase { seed_phrase }) = key {
52+
if let Key::Secret(Secret::SeedPhrase { seed_phrase, .. }) = key {
5353
Ok(seed_phrase)
5454
} else {
5555
Err(Error::UnknownSeedPhrase)

cmd/soroban-cli/src/config/key.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,10 @@ mod test {
176176
#[test]
177177
fn secret_seed_phrase() {
178178
let seed_phrase = "singer swing mango apple singer swing mango apple singer swing mango apple singer swing mango apple".to_string();
179-
let secret = Secret::SeedPhrase { seed_phrase };
179+
let secret = Secret::SeedPhrase {
180+
seed_phrase,
181+
hd_path: None,
182+
};
180183
let key = Key::Secret(secret);
181184
round_trip(&key);
182185
}

0 commit comments

Comments
 (0)