diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 7224f4f..433877e 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -3,17 +3,28 @@ name: Audit on: push: paths: + # Run if workflow changes + - '.github/workflows/audit.yml' + # Run on changed dependencies - '**/Cargo.toml' - '**/Cargo.lock' + # Run if the configuration file changes + - '**/audit.toml' schedule: - cron: '0 0 * * 0' # Once per week + # Run manually + workflow_dispatch: jobs: security_audit: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest + permissions: + contents: read + issues: write steps: - uses: actions/checkout@v4 - - uses: actions-rust-lang/audit@v1 with: - token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + persist-credentials: false + - uses: actions-rust-lang/audit@v1 + name: Audit Rust Dependencies \ No newline at end of file diff --git a/.github/workflows/code_coverage.yml b/.github/workflows/code_coverage.yml index 9dd9cd1..6c2e2df 100644 --- a/.github/workflows/code_coverage.yml +++ b/.github/workflows/code_coverage.yml @@ -9,8 +9,8 @@ jobs: runs-on: ubuntu-latest env: CARGO_INCREMENTAL: '0' - RUSTFLAGS: '-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off' - RUSTDOCFLAGS: '-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off' + RUSTFLAGS: '-Cinstrument-coverage -Ccodegen-units=1 -Cllvm-args=--inline-threshold=0 -Clink-dead-code -Coverflow-checks=off' + RUSTDOCFLAGS: '-Cinstrument-coverage -Ccodegen-units=1 -Cllvm-args=--inline-threshold=0 -Clink-dead-code -Coverflow-checks=off' steps: - name: Checkout @@ -42,6 +42,9 @@ jobs: - name: Test RPC run: cargo test --features rpc + - name: Test Bip322 + run: cargo test --features bip322 + - id: coverage name: Generate coverage uses: actions-rs/grcov@v0.1.5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 15627f0..5c999f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Changelog info is also documented on the [GitHub releases](https://github.com/bi page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. ## [Unreleased] +- Add support for generic BIP-322 signed message formats. ## [0.27.1] - Added hardware signers through the use of HWI. diff --git a/Cargo.lock b/Cargo.lock index 0776f87..35f381a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -177,10 +177,19 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "bdk-bip322" +version = "0.1.0" +source = "git+https://github.com/aagbotemi/bdk-bip322.git?branch=master#608e118bc427c79090356ff2b504633cd431b2fb" +dependencies = [ + "bitcoin", +] + [[package]] name = "bdk-cli" version = "0.27.1" dependencies = [ + "bdk-bip322", "bdk_bitcoind_rpc", "bdk_electrum", "bdk_esplora", @@ -190,6 +199,7 @@ dependencies = [ "dirs", "env_logger", "log", + "rpassword", "serde_json", "shlex", "thiserror 2.0.12", @@ -1213,7 +1223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -1665,6 +1675,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.31.0" diff --git a/Cargo.toml b/Cargo.toml index e1fe742..803be68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ log = "0.4" serde_json = "1.0" thiserror = "2.0.11" tokio = { version = "1", features = ["full"] } +bdk-bip322 = { git = "https://github.com/aagbotemi/bdk-bip322.git", branch = "master", optional = true } # Optional dependencies bdk_bitcoind_rpc = { version = "0.18.0", optional = true } @@ -27,9 +28,11 @@ bdk_electrum = { version = "0.21.0", optional = true } bdk_esplora = { version = "0.20.1", features = ["async-https", "tokio"], optional = true } bdk_kyoto = { version = "0.7.1", optional = true } shlex = { version = "1.3.0", optional = true } +rpassword = "7.4.0" [features] default = ["repl", "sqlite"] +bip322 = ["dep:bdk-bip322"] # To use the app in a REPL mode repl = ["shlex"] diff --git a/bip322_usage.md b/bip322_usage.md new file mode 100644 index 0000000..9247cf1 --- /dev/null +++ b/bip322_usage.md @@ -0,0 +1,143 @@ +# Testing the Bip322 Subcommand + +Ensure you are in a secure environment to prevent key exposure. + +To build, run `cargo build --features bip322` +For testing purposes only, use `L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k` for private key. + +## Signing and Verifying with Different Address Types +1. **Signing and Verifying with Simple** +- Sign a Message(Non-Interactive with `--key_file`): + ```bash + ./target/debug/bdk-cli bip322 sign \ + --key_file /path/to/private_key.txt \ + --message "Hello World" \ + --address bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l \ + --signature_type simple + ``` + + - The file `/path/to/private_key.txt` should contain the WIF private key (e.g., `L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k`). + + +- Sign a Message(Interactive): + ```bash + ./target/debug/bdk-cli bip322 sign \ + --message "Hello World" \ + --address bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l \ + --signature_type simple + ``` + + - When running this command, the tool will prompt: + ```bash + Enter WIF private key: + ``` + - Enter the WIF private key (e.g., `L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k`). The input will not be displayed on the console for security. + +- Expected Output: + ```json + { + "signature": "AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" + } + ``` +- Verify the Signature: + ```bash + ./target/debug/bdk-cli bip322 verify \ + --signature "AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" \ + --message "Hello World" \ + --address bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l \ + --signature_type simple + ``` + + - No private key is required for verifying the `simple` signature type. + +- Expected Output: + ```json + { + "is_valid": true + } + ``` + +2. **Signing and Verifying with Legacy** +- Sign a Message(Non-Interactive with `--key_file`): + ```bash + ./target/debug/bdk-cli bip322 sign \ + --key_file /path/to/private_key.txt \ + --message "Hello World" \ + --address 14vV3aCHBeStb5bkenkNHbe2YAFinYdXgc \ + --signature_type legacy + ``` + - The file `/path/to/private_key.txt` should contain the WIF private key (e.g., `L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k`). + +- Sign a Message (Interactive): + ```bash + ./target/debug/bdk-cli bip322 sign \ + --message "Hello World" \ + --address 14vV3aCHBeStb5bkenkNHbe2YAFinYdXgc \ + --signature_type legacy + ``` + + - When running this command, the tool will prompt: + ```bash + Enter WIF private key: + ``` + - Enter the WIF private key (e.g., `L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k`). The input will not be displayed on the console for security. + + +- Expected Output: + ```json + { + "signature": "MEUCIQDltsYvnmyS3gba+u+JeEB++nag6FYy1fNEfRBShUF+awIgBrlCXPIZaYs8Yuayg0ZqjyiCbLy9pzZIS7JWT65/nsUB" + } + ``` +- Verify the Signature (Non-Interactive with `--key_file`): + ```bash + ./target/debug/bdk-cli bip322 verify \ + --signature "MEUCIQDltsYvnmyS3gba+u+JeEB++nag6FYy1fNEfRBShUF+awIgBrlCXPIZaYs8Yuayg0ZqjyiCbLy9pzZIS7JWT65/nsUB" \ + --message "Hello World" \ + --address 14vV3aCHBeStb5bkenkNHbe2YAFinYdXgc \ + --signature_type legacy \ + --key_file /path/to/private_key.txt + ``` + + - The file /path/to/private_key.txt must contain the WIF private key used for signing. + +- Verify the Signature (Interactive): + ```bash + ./target/debug/bdk-cli bip322 verify \ + --signature "MEUCIQDltsYvnmyS3gba+u+JeEB++nag6FYy1fNEfRBShUF+awIgBrlCXPIZaYs8Yuayg0ZqjyiCbLy9pzZIS7JWT65/nsUB" \ + --message "Hello World" \ + --address 14vV3aCHBeStb5bkenkNHbe2YAFinYdXgc \ + --signature_type legacy + ``` + - When running this command, the tool will prompt: + ```bash + Enter WIF private key: + ``` + - Enter the WIF private key used for signing. + +- Expected Output: + ```json + { + "is_valid": true + } + ``` + +3. Signing and Verifying with Full +- Sign a Message (Non-Interactive with `--key_file`): + Same way with Simple, only change the signature type to full + +- Sign a Message (Interactive): + Same way with Simple, only change the signature type to full + +- Verify the Signature: +Use the signature from the above command in a similar verify command. + +## Notes for Testing +- **Security**: Always handle private keys securely. Use the `--key_file` option or the interactive prompt to avoid exposing keys in command-line arguments or insecure environments. For production use, consider using the `wallet` subcommand for secure key generation. + +- **Error Handling:** If the signature type, address, or private key is invalid, the CLI will return an error message. Test with invalid inputs (e.g., an empty `--key_file` or invalid WIF key) to ensure proper error handling. + +- **Interactive Mode:** When not providing `--key_file`, the tool will prompt for the private key. The input is not echoed to the console for security. If an empty input is provided, an error will be returned. + +- **Legacy Verification:** For the `legacy` signature type, a private key is required during verification. Ensure the same key used for signing is provided via `--key_file` or interactively. + diff --git a/src/commands.rs b/src/commands.rs index bac916c..6a786c8 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -109,6 +109,61 @@ pub enum CliSubCommand { #[command(flatten)] wallet_opts: WalletOpts, }, + /// BIP322 message signing and verification operations. + /// + /// This subcommand allows for standalone signing and verification of messages using the BIP322 + /// standard, without requiring a full wallet setup. It is useful for simple use cases or testing. + /// + /// Available operations: + /// - `sign`: Sign a message using a private key in WIF format. + /// - `verify`: Verify a BIP322 signature for a given message and address. + /// + /// **Security Note**: This subcommand requires direct handling of private keys. Ensure you are in a + /// secure environment to prevent key exposure. For generating keys securely, consider using the `wallet` + /// subcommand instead. + #[cfg(any(feature = "bip322"))] + Bip322 { + #[command(subcommand)] + subcommand: Bip322SubCommand, + }, +} + +#[derive(Debug, Subcommand, Clone, PartialEq)] +#[command(rename_all = "snake")] +pub enum Bip322SubCommand { + /// Sign a message using BIP322 + Sign { + /// Path to a file containing the private key in WIF format. If not provided, you will be prompted to enter the key securely. + #[arg(long)] + key_file: Option, + /// Address to sign + #[arg(long)] + address: String, + /// The message to sign + #[arg(long)] + message: String, + /// The signature format (e.g., Legacy, Simple, Full) + #[arg(long, default_value = "simple")] + signature_type: String, + }, + /// Verify a BIP322 signature + Verify { + /// The address associated with the signature + #[arg(long)] + address: String, + /// Base64-encoded signature + #[arg(long)] + signature: String, + /// The message that was signed + #[arg(long)] + message: String, + /// The signature format (e.g., Legacy, Simple, Full) + #[arg(long, default_value = "simple")] + signature_type: String, + /// Path to a file containing the private key in WIF format. If not provided, you will be prompted to enter the key securely. + #[arg(long)] + key_file: Option, + }, } /// Wallet operation subcommands. diff --git a/src/error.rs b/src/error.rs index ebacc8d..dbf8840 100644 --- a/src/error.rs +++ b/src/error.rs @@ -85,4 +85,8 @@ pub enum BDKCliError { #[cfg(feature = "rpc")] #[error("RPC error: {0}")] BitcoinCoreRpcError(#[from] bdk_bitcoind_rpc::bitcoincore_rpc::Error), + + #[error("BIP322 error: {0}")] + #[cfg(any(feature = "bip322"))] + BIP322Error(#[from] bdk_bip322::Error), } diff --git a/src/handlers.rs b/src/handlers.rs index 0873a23..1c4889a 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -21,6 +21,8 @@ use crate::commands::OnlineWalletSubCommand::*; use crate::commands::*; use crate::error::BDKCliError as Error; use crate::utils::*; +#[cfg(any(feature = "bip322"))] +use bdk_bip322::{SignatureFormat, Signer, Verifier}; use bdk_wallet::bip39::{Language, Mnemonic}; use bdk_wallet::bitcoin::bip32::{DerivationPath, KeySource}; use bdk_wallet::bitcoin::consensus::encode::serialize_hex; @@ -54,6 +56,7 @@ use std::collections::BTreeMap; #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf",))] use std::collections::HashSet; use std::convert::TryFrom; +use std::fs; #[cfg(feature = "repl")] use std::io::Write; use std::str::FromStr; @@ -668,6 +671,96 @@ pub(crate) fn handle_compile_subcommand( Ok(json!({"descriptor": descriptor.to_string()})) } +/// Execute bip322 sub-command +#[cfg(any(feature = "bip322"))] +pub fn handle_bip322_subcommand(subcommand: Bip322SubCommand) -> Result { + match subcommand { + Bip322SubCommand::Sign { + key_file, + address, + message, + signature_type, + } => { + let wif = if let Some(key_file) = key_file { + let content = fs::read_to_string(&key_file) + .map_err(|e| Error::Generic(format!("Failed to read key file: {}", e)))? + .trim() + .to_string(); + if content.is_empty() { + return Err(Error::Generic("Key file is empty".to_string())); + } + content + } else { + let input = rpassword::prompt_password("Enter WIF private key: ") + .map_err(|e| Error::Generic(format!("Failed to read input: {}", e)))? + .trim() + .to_string(); + if input.is_empty() { + return Err(Error::Generic("Private key cannot be empty".to_string())); + } + input + }; + + let signature_format = parse_signature_format(&signature_type)?; + let signer = Signer::new(wif, message, address, signature_format); + let signature = signer.sign()?; + + Ok(json!({"signature": signature})) + } + Bip322SubCommand::Verify { + address, + signature, + message, + signature_type, + key_file, + } => { + let signature_format = parse_signature_format(&signature_type)?; + + let wif_opt: Option = if signature_format == SignatureFormat::Legacy { + if let Some(path) = key_file { + let content = fs::read_to_string(&path) + .map_err(|e| Error::Generic(format!("Failed to read key file: {}", e)))? + .trim() + .to_string(); + if content.is_empty() { + return Err(Error::Generic("Key file is empty".to_string())); + } + Some(content) + } else { + let input = rpassword::prompt_password("Enter WIF private key: ") + .map_err(|e| Error::Generic(format!("Failed to read input: {}", e)))? + .trim() + .to_string(); + if input.is_empty() { + return Err(Error::Generic("Private key cannot be empty".to_string())); + } + Some(input) + } + } else { + None + }; + + let verifier = Verifier::new(address, signature, message, signature_format, wif_opt); + let valid = verifier.verify()?; + + Ok(json!({"valid": valid})) + } + } +} + +/// Function to parse the signature format from a string +#[cfg(any(feature = "bip322"))] +fn parse_signature_format(format_str: &str) -> Result { + match format_str.to_lowercase().as_str() { + "legacy" => Ok(SignatureFormat::Legacy), + "simple" => Ok(SignatureFormat::Simple), + "full" => Ok(SignatureFormat::Full), + _ => Err(Error::Generic( + "Invalid signature format. Use 'legacy', 'simple', or 'full'".to_string(), + )), + } +} + /// The global top level handler. pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { let network = cli_opts.network; @@ -822,6 +915,11 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { } Ok("".to_string()) } + #[cfg(any(feature = "bip322"))] + CliSubCommand::Bip322 { subcommand } => { + let result = handle_bip322_subcommand(subcommand)?; + serde_json::to_string_pretty(&result) + } }; result.map_err(|e| e.into()) }