Skip to content

Commit 9de4fbf

Browse files
committed
Stop echoing a mistyped secret given where a contract id is expected
1 parent a9cb94a commit 9de4fbf

5 files changed

Lines changed: 233 additions & 16 deletions

File tree

‎cmd/crates/soroban-test/tests/it/config.rs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,6 +1312,62 @@ fn token_address_param_error_does_not_leak_secret_bearing_input() {
13121312
}
13131313
}
13141314

1315+
#[test]
1316+
fn contract_id_param_error_does_not_leak_secret_bearing_input() {
1317+
// `--id`/`--contract-id`/`STELLAR_CONTRACT_ID` share `UnresolvedContract`; a
1318+
// mistyped secret pasted there fails to resolve before any network call, so
1319+
// the "contract not found" error must not echo it. `contract fetch --id`
1320+
// stands in for the shared resolve path.
1321+
let sandbox = TestEnv::default();
1322+
// A seed phrase additionally fails alias-name validation (spaces) before the
1323+
// lookup, so cover both the mistyped strkey and the seed-phrase paths.
1324+
for input in [MISTYPED_SECRET, SEED_PHRASE] {
1325+
sandbox
1326+
.new_assert_cmd("contract")
1327+
.args(["fetch", "--id", input, "--network", "testnet"])
1328+
.assert()
1329+
.failure()
1330+
.stdout(predicate::str::contains(input).not())
1331+
.stderr(predicate::str::contains(input).not());
1332+
}
1333+
}
1334+
1335+
#[test]
1336+
fn contract_id_param_error_names_plain_alias() {
1337+
// A genuine alias typo is not secret-bearing, so the error still names it —
1338+
// otherwise a multi-argument command gives no clue which value was wrong.
1339+
let sandbox = TestEnv::default();
1340+
sandbox
1341+
.new_assert_cmd("contract")
1342+
.args(["fetch", "--id", "nosuchalias", "--network", "testnet"])
1343+
.assert()
1344+
.failure()
1345+
.stderr(predicate::str::contains("nosuchalias"));
1346+
}
1347+
1348+
#[test]
1349+
fn address_param_error_names_plain_alias() {
1350+
// Same contract for the address params: a mistyped alias (not secret-bearing)
1351+
// is still echoed so the user can see which value failed.
1352+
let sandbox = TestEnv::default();
1353+
sandbox
1354+
.new_assert_cmd("token")
1355+
.args([
1356+
"balance",
1357+
"--id",
1358+
"native",
1359+
"--account",
1360+
"nosuchalias",
1361+
"--rpc-url",
1362+
"http://localhost:1",
1363+
"--network-passphrase",
1364+
"Test SDF Network ; September 2015",
1365+
])
1366+
.assert()
1367+
.failure()
1368+
.stderr(predicate::str::contains("nosuchalias"));
1369+
}
1370+
13151371
#[test]
13161372
fn help_conceals_sensitive_env_var_values() {
13171373
// Each concealed env var, paired with a subcommand whose `--help` renders it.

‎cmd/soroban-cli/src/config/alias.rs‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::{collections::HashMap, convert::Infallible, str::FromStr};
33
use serde::{Deserialize, Serialize};
44
use stellar_strkey::Contract;
55

6-
use super::locator;
6+
use super::{locator, secret};
77
use crate::config::token::UnresolvedToken;
88
use crate::tx::builder;
99

@@ -60,12 +60,29 @@ pub fn validate_reserved_aliases(alias: &str) -> Result<(), locator::Error> {
6060
}
6161

6262
/// Address can be either a contract address, C.. or eventually an alias of a contract address.
63-
#[derive(Clone, Debug)]
63+
#[derive(Clone)]
6464
pub enum UnresolvedContract {
6565
Resolved(stellar_strkey::Contract),
6666
Alias(String),
6767
}
6868

69+
impl std::fmt::Debug for UnresolvedContract {
70+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71+
match self {
72+
UnresolvedContract::Resolved(contract) => {
73+
f.debug_tuple("Resolved").field(contract).finish()
74+
}
75+
// A genuine alias is safe to show, but never echo a secret key or
76+
// seed phrase pasted where a contract id was expected — e.g. under
77+
// `--verbose`, which debug-prints parsed arguments.
78+
UnresolvedContract::Alias(alias) if secret::looks_like_secret(alias) => {
79+
f.debug_tuple("Alias").field(&"<alias or secret>").finish()
80+
}
81+
UnresolvedContract::Alias(alias) => f.debug_tuple("Alias").field(alias).finish(),
82+
}
83+
}
84+
}
85+
6986
impl Default for UnresolvedContract {
7087
fn default() -> Self {
7188
UnresolvedContract::Alias(String::default())
@@ -102,9 +119,21 @@ impl UnresolvedContract {
102119
locator: &locator::Args,
103120
network_passphrase: &str,
104121
) -> Result<stellar_strkey::Contract, locator::Error> {
105-
locator
106-
.get_contract_id(alias, network_passphrase)?
107-
.ok_or_else(|| locator::Error::ContractNotFound(alias.to_owned()))
122+
let result = locator.get_contract_id(alias, network_passphrase);
123+
124+
// A secret key or seed phrase pasted where a contract id was expected
125+
// must never be echoed. Both a lookup miss and name validation (a seed
126+
// phrase's spaces fail it) otherwise interpolate the raw input, so
127+
// collapse any failure for secret-shaped input to the concealing
128+
// not-found error.
129+
if secret::looks_like_secret(alias) {
130+
return result
131+
.ok()
132+
.flatten()
133+
.ok_or_else(|| locator::Error::ContractNotFound(alias.to_owned()));
134+
}
135+
136+
result?.ok_or_else(|| locator::Error::ContractNotFound(alias.to_owned()))
108137
}
109138
}
110139

@@ -123,4 +152,23 @@ mod tests {
123152
assert!(!is_reserved("my-token"));
124153
assert!(validate_reserved_aliases("my-token").is_ok());
125154
}
155+
156+
#[test]
157+
fn debug_conceals_secret_bearing_alias() {
158+
// `--verbose` debug-prints parsed arguments; a secret pasted where a
159+
// contract id was expected must not surface through the `Alias` variant.
160+
for input in [
161+
"SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BX",
162+
"illness spike retreat truth genius clock brain pass fit cave bargain xyzzy",
163+
] {
164+
let contract = UnresolvedContract::from_str(input).unwrap();
165+
assert!(!format!("{contract:?}").contains(input));
166+
}
167+
}
168+
169+
#[test]
170+
fn debug_echoes_plain_alias() {
171+
let contract = UnresolvedContract::from_str("my-token").unwrap();
172+
assert!(format!("{contract:?}").contains("my-token"));
173+
}
126174
}

‎cmd/soroban-cli/src/config/locator.rs‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use super::{
2222
alias,
2323
key::{self, Key},
2424
network::{self, Network},
25-
secret::Secret,
25+
secret::{self, Secret},
2626
utils, Config,
2727
};
2828

@@ -80,7 +80,7 @@ pub enum Error {
8080
CannotAccessAliasConfigFile,
8181
#[error("cannot parse contract ID {0}: {1}")]
8282
CannotParseContractId(String, DecodeError),
83-
#[error("contract not found: {0}{hint}", hint = wasm_hash_hint(.0))]
83+
#[error("{}", contract_not_found_message(.0))]
8484
ContractNotFound(String),
8585
#[error("Failed to read upgrade check file: {path}: {error}")]
8686
UpgradeCheckReadFailed { path: PathBuf, error: io::Error },
@@ -120,6 +120,18 @@ impl Error {
120120
}
121121
}
122122

123+
// Build the `ContractNotFound` message, concealing the input when it looks like
124+
// a secret key or seed phrase mistyped where a contract id was expected. A
125+
// genuine alias typo is safe to name so the user can see which value was wrong,
126+
// but a mistyped secret must not reach the terminal, logs, or JSON output.
127+
fn contract_not_found_message(value: &str) -> String {
128+
if secret::looks_like_secret(value) {
129+
"contract not found".to_string()
130+
} else {
131+
format!("contract not found: {value}{}", wasm_hash_hint(value))
132+
}
133+
}
134+
123135
fn wasm_hash_hint(value: &str) -> &'static str {
124136
if value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) {
125137
"; expected a contract address (C...), got a hash"
@@ -1084,6 +1096,19 @@ mod error_message_tests {
10841096
let err = Error::ContractNotFound(value.clone());
10851097
assert_eq!(err.to_string(), format!("contract not found: {value}"));
10861098
}
1099+
1100+
#[test]
1101+
fn contract_not_found_conceals_secret_bearing_input() {
1102+
// A mistyped secret key or seed phrase pasted where a contract id was
1103+
// expected must not be echoed back in the not-found error.
1104+
for input in [
1105+
"SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BX",
1106+
"illness spike retreat truth genius clock brain pass fit cave bargain xyzzy",
1107+
] {
1108+
let err = Error::ContractNotFound(input.to_string());
1109+
assert_eq!(err.to_string(), "contract not found");
1110+
}
1111+
}
10871112
}
10881113

10891114
#[cfg(all(test, unix))]

‎cmd/soroban-cli/src/config/sc_address.rs‎

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::str::FromStr;
22

33
use crate::xdr;
44

5-
use super::{alias, key, locator, UnresolvedContract};
5+
use super::{alias, key, locator, secret, UnresolvedContract};
66

77
/// `ScAddress` can be either a resolved `xdr::ScAddress` or an alias of a `Contract` or `MuxedAccount`.
88
#[allow(clippy::module_name_repetitions)]
@@ -16,11 +16,12 @@ impl std::fmt::Debug for UnresolvedScAddress {
1616
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1717
match self {
1818
UnresolvedScAddress::Resolved(addr) => f.debug_tuple("Resolved").field(addr).finish(),
19-
// Never echo the raw input: it may be a secret key or seed phrase
20-
// pasted where an alias was expected.
21-
UnresolvedScAddress::Alias(_) => {
19+
// A genuine alias is safe to show, but never echo a secret key or
20+
// seed phrase pasted where an address was expected.
21+
UnresolvedScAddress::Alias(alias) if secret::looks_like_secret(alias) => {
2222
f.debug_tuple("Alias").field(&"<alias or secret>").finish()
2323
}
24+
UnresolvedScAddress::Alias(alias) => f.debug_tuple("Alias").field(alias).finish(),
2425
}
2526
}
2627
}
@@ -37,10 +38,11 @@ pub enum Error {
3738
Locator(#[from] locator::Error),
3839
#[error(transparent)]
3940
Key(#[from] key::Error),
40-
// The input is never echoed: a secret key or seed phrase mistyped where an
41-
// address was expected must not reach the terminal, logs, or JSON output.
42-
#[error("invalid address or alias")]
43-
AccountAliasNotFound,
41+
// A genuine alias typo is named so the user can see which value was wrong,
42+
// but a secret key or seed phrase mistyped where an address was expected is
43+
// concealed so it never reaches the terminal, logs, or JSON output.
44+
#[error("{}", address_not_found_message(.0))]
45+
AccountAliasNotFound(String),
4446
#[error("alias '{0}' is reserved for the native asset contract but also matches a stored key; pass an explicit contract (C...) or account (G...) address instead")]
4547
ReservedAliasShadowsKey(String),
4648
}
@@ -131,11 +133,22 @@ impl UnresolvedScAddress {
131133
xdr::ScAddress::MuxedAccount(xdr::MuxedEd25519Account { id, ed25519 })
132134
}
133135
}),
134-
_ => Err(Error::AccountAliasNotFound),
136+
_ => Err(Error::AccountAliasNotFound(alias)),
135137
}
136138
}
137139
}
138140

141+
// Build the address "not found" message, concealing the input when it looks like
142+
// a secret key or seed phrase mistyped where an address was expected. A genuine
143+
// alias typo is named so the user can tell which argument and value were wrong.
144+
fn address_not_found_message(value: &str) -> String {
145+
if secret::looks_like_secret(value) {
146+
"invalid address or alias".to_string()
147+
} else {
148+
format!("address alias '{value}' not found")
149+
}
150+
}
151+
139152
#[cfg(test)]
140153
mod tests {
141154
use super::*;
@@ -360,4 +373,27 @@ mod tests {
360373
assert!(!format!("{address:?}").contains(input));
361374
}
362375
}
376+
377+
#[test]
378+
fn resolve_names_plain_alias() {
379+
let dir = tempfile::tempdir().unwrap();
380+
let locator = locator::Args {
381+
config_dir: Some(dir.path().to_path_buf()),
382+
};
383+
let network_passphrase = "Test Network";
384+
385+
// A genuine alias typo is not secret-bearing, so it is still named in the
386+
// error — otherwise a multi-address command gives no clue which value
387+
// failed.
388+
let err = UnresolvedScAddress::Alias("nosuchalias".to_string())
389+
.resolve(&locator, network_passphrase, None)
390+
.unwrap_err();
391+
assert!(err.to_string().contains("nosuchalias"), "got: {err}");
392+
}
393+
394+
#[test]
395+
fn debug_echoes_plain_alias() {
396+
let address = UnresolvedScAddress::from_str("nosuchalias").unwrap();
397+
assert!(format!("{address:?}").contains("nosuchalias"));
398+
}
363399
}

‎cmd/soroban-cli/src/config/secret.rs‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,30 @@ pub enum HardwareKind {
102102
Ledger,
103103
}
104104

105+
/// Whether `value` could be a secret key or seed phrase mistyped where an alias
106+
/// or address was expected. Callers use it to decide whether a "not found" error
107+
/// may echo the raw input: a genuine alias is safe to name so the user can see
108+
/// which value was wrong, but secret-bearing input must be concealed. The check
109+
/// is shape-based on purpose — a one-character typo of a real secret no longer
110+
/// decodes yet still reveals nearly all of it, so matching a successful parse
111+
/// would miss exactly the case that matters.
112+
#[must_use]
113+
pub fn looks_like_secret(value: &str) -> bool {
114+
// A secret seed strkey is `S…`, 56 uppercase base32 characters. Allow slack
115+
// on the length so a typo that drops or adds a character still matches; no
116+
// real alias is a 50+ character all-base32 string beginning with `S`.
117+
let strkey_shaped = value.len() >= 50
118+
&& value.starts_with('S')
119+
&& value
120+
.bytes()
121+
.all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b));
122+
// A seed phrase is 12 or 24 words and a mistyped one keeps the count; an
123+
// alias or address never contains whitespace, so any multi-word input here
124+
// is secret-shaped.
125+
let mnemonic_shaped = value.split_whitespace().count() >= 12;
126+
strkey_shaped || mnemonic_shaped
127+
}
128+
105129
impl FromStr for Secret {
106130
type Err = Error;
107131

@@ -277,6 +301,34 @@ mod tests {
277301
const TEST_SEED_PHRASE: &str =
278302
"depth decade power loud smile spatial sign movie judge february rate broccoli";
279303

304+
#[test]
305+
fn looks_like_secret_matches_secret_shaped_input() {
306+
assert!(looks_like_secret(TEST_SECRET_KEY));
307+
assert!(looks_like_secret(TEST_SEED_PHRASE));
308+
// A valid strkey charset with a bad checksum still looks like a secret.
309+
assert!(looks_like_secret(
310+
"SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BX"
311+
));
312+
// A seed phrase with a bad final word keeps its 12-word shape.
313+
assert!(looks_like_secret(
314+
"illness spike retreat truth genius clock brain pass fit cave bargain xyzzy"
315+
));
316+
}
317+
318+
#[test]
319+
fn looks_like_secret_rejects_aliases_and_ids() {
320+
assert!(!looks_like_secret("alice"));
321+
assert!(!looks_like_secret("my-token"));
322+
assert!(!looks_like_secret("native"));
323+
// A contract id (C…) and a public key (G…) are not secrets.
324+
assert!(!looks_like_secret(
325+
"CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE"
326+
));
327+
assert!(!looks_like_secret(TEST_PUBLIC_KEY));
328+
// A 64-char hex wasm hash begins with a hex digit, never `S`.
329+
assert!(!looks_like_secret(&"a".repeat(64)));
330+
}
331+
280332
#[test]
281333
fn test_from_str_for_secret_key() {
282334
let secret = Secret::from_str(TEST_SECRET_KEY).unwrap();

0 commit comments

Comments
 (0)