Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@ quantus airdrop check --wallet my_wallet
# Also (or only) check an explicit wormhole secret
quantus airdrop check --wallet my_wallet --wormhole-secret-file ./secret.hex
quantus airdrop check --wormhole-secret-file ./secret.hex

# No mnemonic, seed, or secret file? Paste the secret at a hidden prompt
quantus airdrop check --wormhole-secret-prompt
```

Output:
Expand All @@ -380,6 +383,10 @@ Snapshot v1 (3f9c2a81be04) — 1234 rewarded addresses
- `--wormhole-secret-file`: File with a 32-byte hex wormhole secret. On Unix
the file must be a regular file owned by you with no group/other access
(`chmod 600`), like `--password-file`.
- `--wormhole-secret-prompt`: Paste a 32-byte hex wormhole secret at a hidden
prompt instead — for users who only have the raw secret. Secrets are never
accepted as command-line values (they would leak into shell history and
process listings).
- `--wormhole-index`: Pin the HD wormhole address index instead of scanning
`0..=16`; every branch/round is still scanned.
- `--scan-accounts`: Highest Dilithium account index scanned per historical
Expand All @@ -404,6 +411,9 @@ quantus airdrop claim --wallet my_wallet --to qDz...
# Claim from an explicit wormhole secret (requires --to)
quantus airdrop claim --wormhole-secret-file ./secret.hex --to qDz...

# Or paste the secret at a hidden prompt (requires --to)
quantus airdrop claim --wormhole-secret-prompt --to qDz...

# Inspect the exact payloads without submitting anything
quantus airdrop claim --wallet my_wallet --dry-run
```
Expand All @@ -422,8 +432,9 @@ For each match the CLI builds the appropriate proof:
- `--to`: Payout destination (wallet name or SS58). Defaults to `--wallet`'s
own account; required when claiming with only a secret file.
- `--dry-run`: Print the signed/proved claim payloads without POSTing.
- All `check` flags (`--wormhole-secret-file`, `--wormhole-index`,
`--scan-accounts`, `--scan-rounds`, `--server`) work the same here.
- All `check` flags (`--wormhole-secret-file`, `--wormhole-secret-prompt`,
`--wormhole-index`, `--scan-accounts`, `--scan-rounds`, `--server`) work the
same here.

The command exits non-zero if any claim fails, and prints a summary of
recorded, skipped, and failed claims.
Expand Down
80 changes: 78 additions & 2 deletions src/cli/airdrop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ pub enum AirdropCommands {
#[arg(long)]
wormhole_secret_file: Option<PathBuf>,

/// Paste a 32-byte hex wormhole secret at a hidden prompt (secrets
/// are never accepted on argv)
#[arg(long)]
wormhole_secret_prompt: bool,

/// HD wormhole address index, scanned across every branch/round
/// (default: scan indexes 0..=16)
#[arg(long)]
Expand Down Expand Up @@ -98,6 +103,11 @@ pub enum AirdropCommands {
#[arg(long)]
wormhole_secret_file: Option<PathBuf>,

/// Paste a 32-byte hex wormhole secret at a hidden prompt (secrets
/// are never accepted on argv)
#[arg(long)]
wormhole_secret_prompt: bool,

/// HD wormhole address index, scanned across every branch/round
/// (default: scan indexes 0..=16)
#[arg(long)]
Expand Down Expand Up @@ -130,6 +140,7 @@ pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> {
password,
password_file,
wormhole_secret_file,
wormhole_secret_prompt,
wormhole_index,
scan_accounts,
scan_rounds,
Expand All @@ -140,6 +151,7 @@ pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> {
password,
password_file,
wormhole_secret_file,
wormhole_secret_prompt,
ScanWindow { wormhole_index, accounts: scan_accounts, rounds: scan_rounds },
)
.await,
Expand All @@ -150,6 +162,7 @@ pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> {
password,
password_file,
wormhole_secret_file,
wormhole_secret_prompt,
wormhole_index,
scan_accounts,
scan_rounds,
Expand All @@ -162,6 +175,7 @@ pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> {
password,
password_file,
wormhole_secret_file,
wormhole_secret_prompt,
ScanWindow { wormhole_index, accounts: scan_accounts, rounds: scan_rounds },
dry_run,
)
Expand All @@ -188,6 +202,7 @@ async fn handle_check(
password: Option<String>,
password_file: Option<String>,
wormhole_secret_file: Option<PathBuf>,
wormhole_secret_prompt: bool,
scan: ScanWindow,
) -> Result<()> {
let snapshot = fetch_snapshot(&server).await?;
Expand All @@ -196,10 +211,13 @@ async fn handle_check(
password,
password_file,
wormhole_secret_file.as_deref(),
wormhole_secret_prompt,
scan,
)?;
if credentials.dilithium.is_none() && credentials.wormhole_secrets.is_empty() {
return Err(QuantusError::Generic("provide --wallet and/or --wormhole-secret-file".into()));
return Err(QuantusError::Generic(
"provide --wallet, --wormhole-secret-file, and/or --wormhole-secret-prompt".into(),
));
}

let matches = find_matches(&snapshot, &credentials);
Expand All @@ -215,6 +233,7 @@ async fn handle_claim(
password: Option<String>,
password_file: Option<String>,
wormhole_secret_file: Option<PathBuf>,
wormhole_secret_prompt: bool,
scan: ScanWindow,
dry_run: bool,
) -> Result<()> {
Expand All @@ -223,10 +242,13 @@ async fn handle_claim(
password,
password_file,
wormhole_secret_file.as_deref(),
wormhole_secret_prompt,
scan,
)?;
if credentials.dilithium.is_none() && credentials.wormhole_secrets.is_empty() {
return Err(QuantusError::Generic("provide --wallet and/or --wormhole-secret-file".into()));
return Err(QuantusError::Generic(
"provide --wallet, --wormhole-secret-file, and/or --wormhole-secret-prompt".into(),
));
}

let claim_account = resolve_claim_account(to.as_deref(), &credentials)?;
Expand Down Expand Up @@ -338,6 +360,7 @@ fn collect_credentials(
password: Option<String>,
password_file: Option<String>,
wormhole_secret_file: Option<&std::path::Path>,
wormhole_secret_prompt: bool,
scan: ScanWindow,
) -> Result<Credentials> {
let mut wormhole_secrets = Vec::new();
Expand Down Expand Up @@ -382,6 +405,10 @@ fn collect_credentials(
wormhole_secrets.push((secret, path.display().to_string()));
}

if wormhole_secret_prompt {
wormhole_secrets.push((prompt_wormhole_secret()?, "pasted secret".to_string()));
}

Ok(Credentials {
dilithium,
wallet_account,
Expand Down Expand Up @@ -647,6 +674,19 @@ fn mldsa87_keypair(seed: &[u8], v1: bool) -> HistoricalKeypair {
HistoricalKeypair { public: pk.to_vec(), secret }
}

/// Read a pasted wormhole secret from a hidden terminal prompt, for users who
/// hold only the raw secret (no mnemonic or seed) and no secret file. Argv is
/// visible in process listings and shell history, so the secret is never
/// accepted as a command-line value.
fn prompt_wormhole_secret() -> Result<SpendSecret> {
log_print!("{}", "Paste wormhole secret (64 hex chars; input is hidden)".bright_yellow());
let mut hex_str = rpassword::read_password()
.map_err(|e| QuantusError::Generic(format!("Failed to read secret: {e}")))?;
let parsed = parse_secret_hex(&hex_str);
crate::wallet::keystore::zeroize_string(&mut hex_str);
parsed.map(SpendSecret).map_err(QuantusError::Generic)
}

fn read_wormhole_secret(path: &std::path::Path) -> Result<SpendSecret> {
let mut hex_str = password::read_secret_file(
path.to_str()
Expand Down Expand Up @@ -1716,6 +1756,42 @@ mod tests {
);
}

/// #160103: wormhole secrets must not be accepted on argv. The paste
/// prompt (`--wormhole-secret-prompt`) is a bare flag; any variant that
/// takes the secret as a command-line value must fail to parse.
#[test]
fn airdrop_rejects_secret_cli_argument() {
use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "quantus")]
struct TestCli {
#[command(subcommand)]
command: crate::cli::Commands,
}

let secret = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
for args in [
vec!["quantus", "airdrop", "check", "--wormhole-secret", secret],
vec!["quantus", "airdrop", "claim", "--wormhole-secret", secret],
vec!["quantus", "airdrop", "check", "--wormhole-secret-prompt", secret],
vec!["quantus", "airdrop", "claim", "--wormhole-secret-prompt", secret],
] {
let result = TestCli::try_parse_from(args.clone());
assert!(result.is_err(), "airdrop must not accept a secret on argv; args={args:?}");
}

for args in [
vec!["quantus", "airdrop", "check", "--wormhole-secret-prompt"],
vec!["quantus", "airdrop", "claim", "--wormhole-secret-prompt"],
] {
assert!(
TestCli::try_parse_from(args.clone()).is_ok(),
"bare --wormhole-secret-prompt must parse; args={args:?}"
);
}
}

#[test]
fn claim_message_is_address_dest_expiry() {
let address = [1u8; 32];
Expand Down
Loading