Skip to content

Commit 0de9759

Browse files
authored
Persist --hd-path on plain seed-phrase keys (#2540)
1 parent 212ebea commit 0de9759

11 files changed

Lines changed: 344 additions & 29 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 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

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

12101210
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.
12111211

1212-
- `--hd-path <HD_PATH>`With `--as-secret` or `--secure-store`, which `hd_path` to derive the key at from the seed phrase
1212+
- `--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
12131213
- `--fund` — Fund generated key pair
12141214

12151215
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
@@ -351,6 +351,79 @@ async fn rm_with_force_skips_confirmation() {
351351
.failure();
352352
}
353353

354+
// `keys generate --hd-path N` (plain seed-phrase storage) must persist N so
355+
// that later `keys address` calls without `--hd-path` derive at index N rather
356+
// than the default. Guards the user-visible contract from #2538 across CLI
357+
// parsing, identity-file I/O, and key derivation.
358+
#[tokio::test]
359+
async fn hd_path_persists_for_keys_generate() {
360+
let sandbox = &TestEnv::new();
361+
sandbox
362+
.new_assert_cmd("keys")
363+
.args(["generate", "hd-gen", "--hd-path", "5"])
364+
.assert()
365+
.success();
366+
367+
let address_default = pubkey_for_identity(sandbox, "hd-gen");
368+
let address_explicit = sandbox
369+
.new_assert_cmd("keys")
370+
.args(["address", "hd-gen", "--hd-path", "5"])
371+
.assert()
372+
.success()
373+
.stdout_as_str();
374+
let address_zero = sandbox
375+
.new_assert_cmd("keys")
376+
.args(["address", "hd-gen", "--hd-path", "0"])
377+
.assert()
378+
.success()
379+
.stdout_as_str();
380+
381+
assert_eq!(
382+
address_default, address_explicit,
383+
"expected `keys address hd-gen` (no flag) to derive at the persisted hd_path 5"
384+
);
385+
assert_ne!(
386+
address_default, address_zero,
387+
"expected hd_path 5 derivation to differ from hd_path 0"
388+
);
389+
}
390+
391+
#[tokio::test]
392+
async fn hd_path_persists_for_keys_add_seed_phrase() {
393+
let sandbox = &TestEnv::new();
394+
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";
395+
396+
sandbox
397+
.new_assert_cmd("keys")
398+
.write_stdin(format!("{seed_phrase}\n"))
399+
.args(["add", "hd-add", "--hd-path", "5"])
400+
.assert()
401+
.success();
402+
403+
let address_default = pubkey_for_identity(sandbox, "hd-add");
404+
let address_explicit = sandbox
405+
.new_assert_cmd("keys")
406+
.args(["address", "hd-add", "--hd-path", "5"])
407+
.assert()
408+
.success()
409+
.stdout_as_str();
410+
let address_zero = sandbox
411+
.new_assert_cmd("keys")
412+
.args(["address", "hd-add", "--hd-path", "0"])
413+
.assert()
414+
.success()
415+
.stdout_as_str();
416+
417+
assert_eq!(
418+
address_default, address_explicit,
419+
"expected `keys address hd-add` (no flag) to derive at the persisted hd_path 5"
420+
);
421+
assert_ne!(
422+
address_default, address_zero,
423+
"expected hd_path 5 derivation to differ from hd_path 0"
424+
);
425+
}
426+
354427
#[tokio::test]
355428
async fn rm_nonexistent_key() {
356429
let sandbox = &TestEnv::new();

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,35 @@ async fn secure_store_key_management() {
169169
"expected cached public_key to match the address derived at hd_path 5, \
170170
but identity file was:\n{identity_toml}"
171171
);
172+
173+
// Strip the cached public_key but keep hd_path = 5: simulates a legacy
174+
// identity that persisted hd_path but never cached the derived pubkey.
175+
// `keys address` without --hd-path must rederive at the persisted index 5
176+
// and write the index-5 pubkey back to the cache — not silently overwrite
177+
// it with the index-0 pubkey.
178+
let stripped: String = identity_toml
179+
.lines()
180+
.filter(|line| !line.trim_start().starts_with("public_key"))
181+
.collect::<Vec<_>>()
182+
.join("\n");
183+
std::fs::write(&identity_path, stripped).unwrap();
184+
185+
let address_after_rederive = sandbox
186+
.new_assert_cmd("keys")
187+
.args(["address", add_hd_path])
188+
.assert()
189+
.success()
190+
.stdout_as_str();
191+
assert_eq!(
192+
address_after_rederive.trim(),
193+
address_hd_path.trim(),
194+
"expected `keys address {add_hd_path}` (no --hd-path) to derive at the persisted hd_path 5"
195+
);
196+
197+
let identity_toml = std::fs::read_to_string(&identity_path).unwrap();
198+
assert!(
199+
identity_toml.contains(&format!("public_key = \"{}\"", address_hd_path.trim())),
200+
"expected cache write-back to use the persisted hd_path, \
201+
but identity file was:\n{identity_toml}"
202+
);
172203
}

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: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ 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,
4245
}
4346

4447
#[derive(Debug, clap::Parser, Clone)]
@@ -54,15 +57,23 @@ pub struct Cmd {
5457
pub config_locator: locator::Args,
5558

5659
/// Add a public key, ed25519, or muxed account, e.g. G1.., M2..
57-
#[arg(long, conflicts_with = "seed_phrase", conflicts_with = "secret_key")]
60+
#[arg(
61+
long,
62+
conflicts_with = "seed_phrase",
63+
conflicts_with = "secret_key",
64+
conflicts_with = "hd_path"
65+
)]
5866
pub public_key: Option<String>,
5967

6068
/// Overwrite existing identity if it already exists. When combined with
6169
/// --secure-store, also replaces the existing Secure Store entry.
6270
#[arg(long)]
6371
pub overwrite: bool,
6472

65-
/// When importing a seed phrase into the Secure Store, which `hd_path` to derive the key at.
73+
/// When importing a seed phrase, which `hd_path` to derive the key at.
74+
/// Persisted on the identity so later commands derive the same account
75+
/// without re-passing the flag. Not valid with `--public-key` or a raw
76+
/// secret key.
6677
#[arg(long)]
6778
pub hd_path: Option<usize>,
6879
}
@@ -98,7 +109,7 @@ impl Cmd {
98109
return Err(Error::SecureStoreRequiresSeedPhrase);
99110
}
100111
} else if let Ok(secret_key) = std::env::var("STELLAR_SECRET_KEY") {
101-
return Ok(secret_key.parse()?);
112+
return build_secret(&secret_key, self.hd_path);
102113
}
103114

104115
if self.secrets.secure_store {
@@ -123,8 +134,8 @@ impl Cmd {
123134
} else {
124135
let prompt = "Type a secret key or 12/24 word seed phrase:";
125136
let secret_key = read_password(print, prompt)?;
126-
let secret = secret_key.parse()?;
127-
if let Secret::SeedPhrase { seed_phrase } = &secret {
137+
let secret = build_secret(&secret_key, self.hd_path)?;
138+
if let Secret::SeedPhrase { seed_phrase, .. } = &secret {
128139
if seed_phrase.split_whitespace().count() < 24 {
129140
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());
130141
print.warnln(
@@ -138,6 +149,18 @@ impl Cmd {
138149
}
139150
}
140151

152+
fn build_secret(input: &str, hd_path: Option<usize>) -> Result<Secret, Error> {
153+
let secret: Secret = input.parse()?;
154+
match (secret, hd_path) {
155+
(Secret::SecretKey { .. }, Some(_)) => Err(Error::HdPathNotSupportedForSecretKey),
156+
(Secret::SeedPhrase { seed_phrase, .. }, hd_path) => Ok(Secret::SeedPhrase {
157+
seed_phrase,
158+
hd_path,
159+
}),
160+
(secret, _) => Ok(secret),
161+
}
162+
}
163+
141164
fn read_password(print: &Print, prompt: &str) -> Result<String, Error> {
142165
if std::io::stdin().is_terminal() {
143166
// Interactive: prompt and read from TTY
@@ -190,13 +213,84 @@ mod tests {
190213
(temp_dir, locator, cmd)
191214
}
192215

216+
fn cmd_with_public_key(
217+
public_key: &str,
218+
hd_path: Option<usize>,
219+
) -> (tempfile::TempDir, locator::Args, Cmd) {
220+
let (temp_dir, locator, mut cmd) = set_up_test();
221+
cmd.public_key = Some(public_key.to_string());
222+
cmd.hd_path = hd_path;
223+
(temp_dir, locator, cmd)
224+
}
225+
193226
fn global_args() -> global::Args {
194227
global::Args {
195228
quiet: true,
196229
..Default::default()
197230
}
198231
}
199232

233+
#[test]
234+
fn test_build_secret_persists_hd_path_on_seed_phrase() {
235+
let secret = build_secret(SEED_PHRASE, Some(5)).unwrap();
236+
match secret {
237+
Secret::SeedPhrase {
238+
seed_phrase,
239+
hd_path,
240+
} => {
241+
assert_eq!(seed_phrase, SEED_PHRASE);
242+
assert_eq!(hd_path, Some(5));
243+
}
244+
other => panic!("expected SeedPhrase variant, got {other:?}"),
245+
}
246+
}
247+
248+
#[test]
249+
fn test_build_secret_seed_phrase_without_hd_path() {
250+
let secret = build_secret(SEED_PHRASE, None).unwrap();
251+
match secret {
252+
Secret::SeedPhrase { hd_path, .. } => assert_eq!(hd_path, None),
253+
other => panic!("expected SeedPhrase variant, got {other:?}"),
254+
}
255+
}
256+
257+
#[test]
258+
fn test_build_secret_rejects_hd_path_with_secret_key() {
259+
let result = build_secret(SECRET_KEY, Some(5));
260+
assert!(matches!(result, Err(Error::HdPathNotSupportedForSecretKey)));
261+
}
262+
263+
#[test]
264+
fn test_build_secret_secret_key_without_hd_path() {
265+
let secret = build_secret(SECRET_KEY, None).unwrap();
266+
assert!(matches!(secret, Secret::SecretKey { .. }));
267+
}
268+
269+
#[test]
270+
fn test_clap_rejects_hd_path_with_public_key() {
271+
// clap-level conflict: --public-key cannot be combined with --hd-path.
272+
// Driving through `try_parse_from` rather than constructing `Cmd`
273+
// directly is what exercises the conflict.
274+
use clap::Parser;
275+
276+
let result = Cmd::try_parse_from([
277+
"add",
278+
"test_name",
279+
"--public-key",
280+
PUBLIC_KEY,
281+
"--hd-path",
282+
"3",
283+
]);
284+
let err = result.expect_err("clap must reject --public-key + --hd-path");
285+
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
286+
}
287+
288+
#[test]
289+
fn test_run_accepts_public_key_without_hd_path() {
290+
let (_tmp, _locator, cmd) = cmd_with_public_key(PUBLIC_KEY, None);
291+
assert!(cmd.run(&global_args()).is_ok());
292+
}
293+
200294
#[test]
201295
fn public_key_flag_accepts_public_key() {
202296
let (_tmp, locator, mut cmd) = set_up_test();

0 commit comments

Comments
 (0)