Skip to content

Commit 008c002

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 008c002

9 files changed

Lines changed: 320 additions & 24 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/integration/keys.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,79 @@ async fn rm_with_force_skips_confirmation() {
301301
.failure();
302302
}
303303

304+
// `keys generate --hd-path N` (plain seed-phrase storage) must persist N so
305+
// that later `keys address` calls without `--hd-path` derive at index N rather
306+
// than the default. Guards the user-visible contract from #2538 across CLI
307+
// parsing, identity-file I/O, and key derivation.
308+
#[tokio::test]
309+
async fn hd_path_persists_for_keys_generate() {
310+
let sandbox = &TestEnv::new();
311+
sandbox
312+
.new_assert_cmd("keys")
313+
.args(["generate", "hd-gen", "--hd-path", "5"])
314+
.assert()
315+
.success();
316+
317+
let address_default = pubkey_for_identity(sandbox, "hd-gen");
318+
let address_explicit = sandbox
319+
.new_assert_cmd("keys")
320+
.args(["address", "hd-gen", "--hd-path", "5"])
321+
.assert()
322+
.success()
323+
.stdout_as_str();
324+
let address_zero = sandbox
325+
.new_assert_cmd("keys")
326+
.args(["address", "hd-gen", "--hd-path", "0"])
327+
.assert()
328+
.success()
329+
.stdout_as_str();
330+
331+
assert_eq!(
332+
address_default, address_explicit,
333+
"expected `keys address hd-gen` (no flag) to derive at the persisted hd_path 5"
334+
);
335+
assert_ne!(
336+
address_default, address_zero,
337+
"expected hd_path 5 derivation to differ from hd_path 0"
338+
);
339+
}
340+
341+
#[tokio::test]
342+
async fn hd_path_persists_for_keys_add_seed_phrase() {
343+
let sandbox = &TestEnv::new();
344+
let seed_phrase = "aisle reflect depart add safe fury dress artist bronze abuse warrior clap inquiry ask mandate deputy view trade debate flip priority boy depart recipe";
345+
346+
sandbox
347+
.new_assert_cmd("keys")
348+
.write_stdin(format!("{seed_phrase}\n"))
349+
.args(["add", "hd-add", "--hd-path", "5"])
350+
.assert()
351+
.success();
352+
353+
let address_default = pubkey_for_identity(sandbox, "hd-add");
354+
let address_explicit = sandbox
355+
.new_assert_cmd("keys")
356+
.args(["address", "hd-add", "--hd-path", "5"])
357+
.assert()
358+
.success()
359+
.stdout_as_str();
360+
let address_zero = sandbox
361+
.new_assert_cmd("keys")
362+
.args(["address", "hd-add", "--hd-path", "0"])
363+
.assert()
364+
.success()
365+
.stdout_as_str();
366+
367+
assert_eq!(
368+
address_default, address_explicit,
369+
"expected `keys address hd-add` (no flag) to derive at the persisted hd_path 5"
370+
);
371+
assert_ne!(
372+
address_default, address_zero,
373+
"expected hd_path 5 derivation to differ from hd_path 0"
374+
);
375+
}
376+
304377
#[tokio::test]
305378
async fn rm_nonexistent_key() {
306379
let sandbox = &TestEnv::new();

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: 32 additions & 9 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

@@ -140,7 +146,7 @@ impl Cmd {
140146
mod tests {
141147
use crate::config::{address::KeyName, key::Key, secret::Secret};
142148

143-
fn set_up_test() -> (super::locator::Args, super::Cmd) {
149+
fn set_up_test() -> (super::locator::Args, super::Cmd, tempfile::TempDir) {
144150
let temp_dir = tempfile::tempdir().unwrap();
145151
let locator = super::locator::Args {
146152
config_dir: Some(temp_dir.path().to_path_buf()),
@@ -158,7 +164,7 @@ mod tests {
158164
overwrite: false,
159165
};
160166

161-
(locator, cmd)
167+
(locator, cmd, temp_dir)
162168
}
163169

164170
fn global_args() -> super::global::Args {
@@ -170,7 +176,7 @@ mod tests {
170176

171177
#[tokio::test]
172178
async fn test_storing_secret_as_a_seed_phrase() {
173-
let (test_locator, cmd) = set_up_test();
179+
let (test_locator, cmd, _temp_dir) = set_up_test();
174180
let global_args = global_args();
175181

176182
let result = cmd.run(&global_args).await;
@@ -179,9 +185,26 @@ 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, _temp_dir) = 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() {
184-
let (test_locator, mut cmd) = set_up_test();
207+
let (test_locator, mut cmd, _temp_dir) = set_up_test();
185208
cmd.as_secret = true;
186209
let global_args = global_args();
187210

@@ -196,7 +219,7 @@ mod tests {
196219
async fn test_storing_secret_in_secure_store() {
197220
use keyring::{mock, set_default_credential_builder};
198221
set_default_credential_builder(mock::default_credential_builder());
199-
let (test_locator, mut cmd) = set_up_test();
222+
let (test_locator, mut cmd, _temp_dir) = set_up_test();
200223
cmd.secure_store = true;
201224
let global_args = global_args();
202225

@@ -211,7 +234,7 @@ mod tests {
211234
async fn test_generate_secure_store_caches_public_key_on_disk() {
212235
use keyring::{mock, set_default_credential_builder};
213236
set_default_credential_builder(mock::default_credential_builder());
214-
let (test_locator, mut cmd) = set_up_test();
237+
let (test_locator, mut cmd, _temp_dir) = set_up_test();
215238
cmd.secure_store = true;
216239
let global_args = global_args();
217240

@@ -237,7 +260,7 @@ mod tests {
237260
#[cfg(not(feature = "additional-libs"))]
238261
#[tokio::test]
239262
async fn test_storing_in_secure_store_returns_error_when_additional_libs_not_enabled() {
240-
let (test_locator, mut cmd) = set_up_test();
263+
let (test_locator, mut cmd, _temp_dir) = set_up_test();
241264
cmd.secure_store = true;
242265
let global_args = global_args();
243266

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)

0 commit comments

Comments
 (0)