From 32deb5931a77b1ce4fc66e5c9385e9d880558322 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 22 Sep 2026 10:48:14 +0800 Subject: [PATCH] Add --wormhole-secret-prompt for pasting a raw secret Users who hold only the raw 32-byte wormhole secret (no mnemonic, seed, or secret file) can paste it at a hidden rpassword prompt on airdrop check and claim. Secrets stay off argv per #160103; a regression test asserts the airdrop commands reject any command-line secret value and that the bare flag parses. The pasted hex is parsed with the stack-buffer decoder and the input string is zeroized. Co-authored-by: Cursor --- README.md | 15 +++++++-- src/cli/airdrop.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4adda70..65b6c3d 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 @@ -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 ``` @@ -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. diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 115793b..5f62a9d 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -53,6 +53,11 @@ pub enum AirdropCommands { #[arg(long)] wormhole_secret_file: Option, + /// 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)] @@ -98,6 +103,11 @@ pub enum AirdropCommands { #[arg(long)] wormhole_secret_file: Option, + /// 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)] @@ -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, @@ -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, @@ -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, @@ -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, ) @@ -188,6 +202,7 @@ async fn handle_check( password: Option, password_file: Option, wormhole_secret_file: Option, + wormhole_secret_prompt: bool, scan: ScanWindow, ) -> Result<()> { let snapshot = fetch_snapshot(&server).await?; @@ -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); @@ -215,6 +233,7 @@ async fn handle_claim( password: Option, password_file: Option, wormhole_secret_file: Option, + wormhole_secret_prompt: bool, scan: ScanWindow, dry_run: bool, ) -> Result<()> { @@ -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)?; @@ -338,6 +360,7 @@ fn collect_credentials( password: Option, password_file: Option, wormhole_secret_file: Option<&std::path::Path>, + wormhole_secret_prompt: bool, scan: ScanWindow, ) -> Result { let mut wormhole_secrets = Vec::new(); @@ -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, @@ -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 { + 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 { let mut hex_str = password::read_secret_file( path.to_str() @@ -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];