mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-04-23 22:20:00 -07:00
Merge pull request #18 from satsfy/add-cli
refactor: implement stealth cli as rust crate
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,4 +12,6 @@ dist/
|
||||
.qwen
|
||||
**/__pycache__/
|
||||
target/
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
bitcoin.conf
|
||||
.bitcoin-regtest
|
||||
|
||||
@@ -3,6 +3,7 @@ members = [
|
||||
"model",
|
||||
"bitcoincore",
|
||||
"engine",
|
||||
"cli",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
52
README.md
52
README.md
@@ -31,6 +31,7 @@ Stealth ships a Rust workspace with:
|
||||
|
||||
- `stealth-engine` (analysis engine)
|
||||
- `stealth-model` (domain model types and interfaces)
|
||||
- `stealth-cli`
|
||||
- `stealth-bitcoincore` (Bitcoin Core RPC gateway adapter)
|
||||
|
||||
## Project Direction
|
||||
@@ -150,51 +151,50 @@ Stealth currently runs **12 detectors** in `stealth-engine`.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Dependency | Version | Purpose |
|
||||
| -------------- | ------- | --------------- |
|
||||
| Bitcoin Core | ≥ 26 | Local node |
|
||||
| Python | ≥ 3.10 | Analysis engine |
|
||||
| Java | 21 | Backend |
|
||||
| Node.js + yarn | ≥ 18 | Frontend |
|
||||
| Dependency | Version | Purpose |
|
||||
| -------------- | ------- | ----------------- |
|
||||
| Bitcoin Core | ≥ 26 | Local node |
|
||||
| Rust toolchain | ≥ 1.56 | CLI + engine |
|
||||
| Node.js + yarn | ≥ 18 | Frontend |
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/stealth-bitcoin/stealth.git
|
||||
cd stealth
|
||||
cargo build
|
||||
```
|
||||
|
||||
### 2. Configure blockchain connection
|
||||
### 2. Configure Bitcoin Core RPC (regtest)
|
||||
|
||||
Edit:
|
||||
|
||||
```
|
||||
backend/script/config.ini
|
||||
```
|
||||
|
||||
### 3. Development setup (regtest)
|
||||
|
||||
A regtest environment is provided for development and reproducible testing of heuristics.
|
||||
Copy the example config:
|
||||
|
||||
```bash
|
||||
cd backend/script
|
||||
./setup.sh
|
||||
cp bitcoin.conf.example bitcoin.conf
|
||||
```
|
||||
|
||||
### 4. Generate sample transactions
|
||||
### 3. Start regtest and fund a wallet
|
||||
|
||||
```bash
|
||||
python3 reproduce.py
|
||||
./scripts/setup.sh
|
||||
```
|
||||
|
||||
### 5. Start backend
|
||||
This starts `bitcoind` in regtest mode, creates a wallet, mines initial blocks,
|
||||
and prints the descriptor and a ready-to-use `stealth-cli` command.
|
||||
|
||||
Use `./scripts/setup.sh --fresh` to wipe the chain and start from genesis.
|
||||
|
||||
### 4. Run a CLI scan
|
||||
|
||||
```bash
|
||||
cd backend/src/StealthBackend
|
||||
./mvnw quarkus:dev
|
||||
cargo run --bin stealth-cli -- scan \
|
||||
--descriptor '<descriptor from setup.sh output>' \
|
||||
--rpc-url http://127.0.0.1:18443 \
|
||||
--rpc-cookie .bitcoin-regtest/regtest/.cookie \
|
||||
--format text
|
||||
```
|
||||
|
||||
### 6. Start frontend
|
||||
### 5. Start frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
@@ -231,7 +231,9 @@ stealth/
|
||||
│ │ ├── config.ini # Connection config (datadir, network)
|
||||
│ │ └── bitcoin-data/ # Regtest chain data (gitignored)
|
||||
│ └── src/StealthBackend/ # Quarkus Java REST API (single /api/wallet/scan endpoint)
|
||||
└── slides/ # Slidev pitch presentation
|
||||
├── cli/ # stealth-cli
|
||||
├── scripts/ # Development helper scripts (setup.sh)
|
||||
└── target/ # Cargo build outputs
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
12
bitcoin.conf.example
Normal file
12
bitcoin.conf.example
Normal file
@@ -0,0 +1,12 @@
|
||||
regtest=1
|
||||
server=1
|
||||
daemon=1
|
||||
txindex=1
|
||||
listen=0
|
||||
[regtest]
|
||||
rpcbind=127.0.0.1
|
||||
rpcallowip=127.0.0.1
|
||||
rpcuser=localuser
|
||||
rpcpassword=localpass
|
||||
rpcport=18443
|
||||
fallbackfee=0.0002
|
||||
21
cli/Cargo.toml
Normal file
21
cli/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "stealth-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Detects UTXO privacy vulnerabilities in wallets"
|
||||
categories = ["cryptography::cryptocurrencies"]
|
||||
keywords = ["bitcoin", "privacy", "cli"]
|
||||
exclude = ["tests"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
stealth-bitcoincore = { path = "../bitcoincore" }
|
||||
stealth-engine = { workspace = true }
|
||||
|
||||
[lints.rust]
|
||||
missing_debug_implementations = "deny"
|
||||
250
cli/src/main.rs
Normal file
250
cli/src/main.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::{env, fs};
|
||||
|
||||
use stealth_bitcoincore::{read_cookie_file, BitcoinCoreRpc};
|
||||
use stealth_engine::engine::{AnalysisEngine, EngineSettings, ScanTarget, UtxoInput};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
|
||||
if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
|
||||
print_usage();
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
if args[0] != "scan" {
|
||||
eprintln!(
|
||||
"error: unknown command '{}' (try 'stealth-cli --help')",
|
||||
args[0]
|
||||
);
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
|
||||
match run_scan(&args[1..]) {
|
||||
Ok(clean) => {
|
||||
if clean {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
eprintln!("error: {message}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_scan(args: &[String]) -> Result<bool, String> {
|
||||
let opts = parse_scan_args(args)?;
|
||||
let gateway = opts.build_gateway()?;
|
||||
let target = opts.scan_target()?;
|
||||
|
||||
let engine = AnalysisEngine::new(&gateway, EngineSettings::default());
|
||||
let report = engine.analyze(target).map_err(|e| e.to_string())?;
|
||||
|
||||
match opts.format.as_deref() {
|
||||
Some("text") | None => print_text_report(&report),
|
||||
Some("json") => {
|
||||
let json = serde_json::to_string_pretty(&report)
|
||||
.map_err(|e| format!("serialization failed: {e}"))?;
|
||||
println!("{json}");
|
||||
}
|
||||
Some(other) => return Err(format!("unsupported format '{other}' (use json or text)")),
|
||||
}
|
||||
|
||||
Ok(report.summary.clean)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ScanOpts {
|
||||
descriptor: Option<String>,
|
||||
descriptors_file: Option<PathBuf>,
|
||||
utxos_file: Option<PathBuf>,
|
||||
rpc_url: Option<String>,
|
||||
rpc_user: Option<String>,
|
||||
rpc_cookie: Option<PathBuf>,
|
||||
format: Option<String>,
|
||||
}
|
||||
|
||||
impl ScanOpts {
|
||||
fn build_gateway(&self) -> Result<BitcoinCoreRpc, String> {
|
||||
let url = self
|
||||
.rpc_url
|
||||
.clone()
|
||||
.or_else(|| env::var("STEALTH_RPC_URL").ok())
|
||||
.ok_or("--rpc-url or STEALTH_RPC_URL is required")?;
|
||||
|
||||
let (user, pass) = match (
|
||||
self.rpc_user
|
||||
.clone()
|
||||
.or_else(|| env::var("STEALTH_RPC_USER").ok()),
|
||||
env::var("STEALTH_RPC_PASS").ok(),
|
||||
self.rpc_cookie
|
||||
.clone()
|
||||
.or_else(|| env::var("STEALTH_RPC_COOKIE").ok().map(PathBuf::from)),
|
||||
) {
|
||||
(Some(user), Some(pass), _) => (Some(user), Some(pass)),
|
||||
(_, _, Some(cookie_path)) => {
|
||||
let (u, p) = read_cookie_file(&cookie_path).map_err(|e| e.to_string())?;
|
||||
(Some(u), Some(p))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
BitcoinCoreRpc::from_url(&url, user, pass).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn scan_target(&self) -> Result<ScanTarget, String> {
|
||||
let mut sources = 0usize;
|
||||
if self.descriptor.is_some() {
|
||||
sources += 1;
|
||||
}
|
||||
if self.descriptors_file.is_some() {
|
||||
sources += 1;
|
||||
}
|
||||
if self.utxos_file.is_some() {
|
||||
sources += 1;
|
||||
}
|
||||
|
||||
if sources == 0 {
|
||||
return Err(
|
||||
"one input is required: --descriptor, --descriptors, or --utxos".to_owned(),
|
||||
);
|
||||
}
|
||||
if sources > 1 {
|
||||
return Err(
|
||||
"--descriptor, --descriptors, and --utxos are mutually exclusive".to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(d) = &self.descriptor {
|
||||
return Ok(ScanTarget::Descriptor(d.clone()));
|
||||
}
|
||||
if let Some(path) = &self.descriptors_file {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
||||
let descriptors: Vec<String> = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("invalid JSON in {}: {e}", path.display()))?;
|
||||
return Ok(ScanTarget::Descriptors(descriptors));
|
||||
}
|
||||
if let Some(path) = &self.utxos_file {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
||||
let utxos: Vec<UtxoInput> = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("invalid JSON in {}: {e}", path.display()))?;
|
||||
return Ok(ScanTarget::Utxos(utxos));
|
||||
}
|
||||
|
||||
Err("no scan target specified".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_scan_args(args: &[String]) -> Result<ScanOpts, String> {
|
||||
let mut opts = ScanOpts::default();
|
||||
let mut i = 0;
|
||||
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--descriptor" => {
|
||||
opts.descriptor = Some(take_value(args, &mut i, "--descriptor")?);
|
||||
}
|
||||
"--descriptors" => {
|
||||
opts.descriptors_file =
|
||||
Some(PathBuf::from(take_value(args, &mut i, "--descriptors")?));
|
||||
}
|
||||
"--utxos" => {
|
||||
opts.utxos_file = Some(PathBuf::from(take_value(args, &mut i, "--utxos")?));
|
||||
}
|
||||
"--rpc-url" => {
|
||||
opts.rpc_url = Some(take_value(args, &mut i, "--rpc-url")?);
|
||||
}
|
||||
"--rpc-user" => {
|
||||
opts.rpc_user = Some(take_value(args, &mut i, "--rpc-user")?);
|
||||
}
|
||||
"--rpc-cookie" => {
|
||||
opts.rpc_cookie = Some(PathBuf::from(take_value(args, &mut i, "--rpc-cookie")?));
|
||||
}
|
||||
"--format" => {
|
||||
opts.format = Some(take_value(args, &mut i, "--format")?);
|
||||
}
|
||||
other => return Err(format!("unknown flag '{other}'")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
fn take_value(args: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
|
||||
*i += 1;
|
||||
let value = args
|
||||
.get(*i)
|
||||
.ok_or_else(|| format!("{flag} requires a value"))?;
|
||||
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} requires a value"));
|
||||
}
|
||||
|
||||
Ok(value.clone())
|
||||
}
|
||||
|
||||
fn print_text_report(report: &stealth_engine::Report) {
|
||||
println!(
|
||||
"Scanned {} transactions, {} addresses, {} current UTXOs\n",
|
||||
report.stats.transactions_analyzed, report.stats.addresses_seen, report.stats.utxos_current,
|
||||
);
|
||||
|
||||
if report.summary.clean {
|
||||
println!("No privacy issues found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if !report.findings.is_empty() {
|
||||
println!("Findings ({}):", report.findings.len());
|
||||
for f in &report.findings {
|
||||
println!(
|
||||
" [{severity}] {vtype}: {desc}",
|
||||
severity = f.severity,
|
||||
vtype = f.vulnerability_type,
|
||||
desc = f.description,
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !report.warnings.is_empty() {
|
||||
println!("Warnings ({}):", report.warnings.len());
|
||||
for w in &report.warnings {
|
||||
println!(
|
||||
" [{severity}] {vtype}: {desc}",
|
||||
severity = w.severity,
|
||||
vtype = w.vulnerability_type,
|
||||
desc = w.description,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!("stealth-cli – Bitcoin UTXO privacy vulnerability scanner\n");
|
||||
eprintln!("USAGE:");
|
||||
eprintln!(" stealth-cli scan [OPTIONS]\n");
|
||||
eprintln!("SCAN INPUT (one required, mutually exclusive):");
|
||||
eprintln!(" --descriptor <DESC> Single output descriptor");
|
||||
eprintln!(" --descriptors <FILE> JSON array of descriptors");
|
||||
eprintln!(" --utxos <FILE> JSON array of {{txid,vout,...}}\n");
|
||||
eprintln!("RPC CONNECTION:");
|
||||
eprintln!(" --rpc-url <URL> bitcoind RPC endpoint");
|
||||
eprintln!(" --rpc-user <USER> RPC username");
|
||||
eprintln!(" --rpc-cookie <PATH> Path to .cookie file (recommended)\n");
|
||||
eprintln!(" Env vars: STEALTH_RPC_URL, STEALTH_RPC_USER,");
|
||||
eprintln!(" STEALTH_RPC_PASS, STEALTH_RPC_COOKIE\n");
|
||||
eprintln!("OUTPUT:");
|
||||
eprintln!(" --format <text|json> Output format (default: text)\n");
|
||||
eprintln!("EXIT CODES:");
|
||||
eprintln!(" 0 scan completed, no findings");
|
||||
eprintln!(" 1 scan completed, findings present");
|
||||
eprintln!(" 2 error");
|
||||
}
|
||||
@@ -1,24 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bitcoin::Amount;
|
||||
|
||||
/// Identifies a specific detector for enable/disable configuration.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DetectorId {
|
||||
AddressReuse,
|
||||
Cioh,
|
||||
Dust,
|
||||
DustSpending,
|
||||
ChangeDetection,
|
||||
Consolidation,
|
||||
ScriptTypeMixing,
|
||||
ClusterMerge,
|
||||
UtxoAgeSpread,
|
||||
ExchangeOrigin,
|
||||
TaintedUtxoMerge,
|
||||
BehavioralFingerprint,
|
||||
}
|
||||
|
||||
/// Numeric thresholds used by the detectors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectorThresholds {
|
||||
@@ -58,7 +39,6 @@ impl Default for DetectorThresholds {
|
||||
pub struct AnalysisConfig {
|
||||
pub derivation_range_end: u32,
|
||||
pub thresholds: DetectorThresholds,
|
||||
pub enabled_detectors: HashSet<DetectorId>,
|
||||
/// Maximum ancestor-fetch depth when resolving UTXO history.
|
||||
/// `0` means only UTXO's own tx; `2` (the default)
|
||||
pub max_ancestor_depth: u32,
|
||||
@@ -69,20 +49,6 @@ impl Default for AnalysisConfig {
|
||||
Self {
|
||||
derivation_range_end: 999,
|
||||
thresholds: DetectorThresholds::default(),
|
||||
enabled_detectors: HashSet::from([
|
||||
DetectorId::AddressReuse,
|
||||
DetectorId::Cioh,
|
||||
DetectorId::Dust,
|
||||
DetectorId::DustSpending,
|
||||
DetectorId::ChangeDetection,
|
||||
DetectorId::Consolidation,
|
||||
DetectorId::ScriptTypeMixing,
|
||||
DetectorId::ClusterMerge,
|
||||
DetectorId::UtxoAgeSpread,
|
||||
DetectorId::ExchangeOrigin,
|
||||
DetectorId::TaintedUtxoMerge,
|
||||
DetectorId::BehavioralFingerprint,
|
||||
]),
|
||||
max_ancestor_depth: 2,
|
||||
}
|
||||
}
|
||||
|
||||
100
scripts/setup.sh
Executable file
100
scripts/setup.sh
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# setup.sh — Bootstrap Bitcoin Core regtest for stealth-cli development
|
||||
# =============================================================================
|
||||
# Creates a local regtest environment with a funded wallet, then prints the
|
||||
# descriptor and a ready-to-use stealth-cli command.
|
||||
#
|
||||
# Prerequisites: bitcoind, bitcoin-cli, cargo (Rust toolchain).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup.sh # keep existing chain state
|
||||
# ./scripts/setup.sh --fresh # wipe regtest, start from genesis
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONF="$REPO_DIR/bitcoin.conf"
|
||||
DATADIR="$REPO_DIR/.bitcoin-regtest"
|
||||
WALLET="scanwallet_cli"
|
||||
INITIAL_BLOCKS=101
|
||||
|
||||
# ─── Parse args ───────────────────────────────────────────────────────────────
|
||||
FRESH=0
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == "--fresh" ]] && FRESH=1
|
||||
done
|
||||
|
||||
# ─── Ensure bitcoin.conf exists ──────────────────────────────────────────────
|
||||
if [[ ! -f "$CONF" ]]; then
|
||||
if [[ -f "$REPO_DIR/bitcoin.conf.example" ]]; then
|
||||
cp "$REPO_DIR/bitcoin.conf.example" "$CONF"
|
||||
echo "Copied bitcoin.conf.example → bitcoin.conf"
|
||||
else
|
||||
echo "error: bitcoin.conf not found (copy bitcoin.conf.example first)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
bcli() { bitcoin-cli -datadir="$DATADIR" -conf="$CONF" -regtest -rpcport=18443 "$@"; }
|
||||
|
||||
# ─── Optionally wipe regtest chain ───────────────────────────────────────────
|
||||
if [[ $FRESH -eq 1 ]]; then
|
||||
bcli stop 2>/dev/null || true
|
||||
sleep 2
|
||||
rm -rf "$DATADIR"
|
||||
echo "Wiped regtest data"
|
||||
fi
|
||||
|
||||
# ─── Start bitcoind if not running ───────────────────────────────────────────
|
||||
mkdir -p "$DATADIR"
|
||||
if ! bcli getblockchaininfo >/dev/null 2>&1; then
|
||||
bitcoind -datadir="$DATADIR" -conf="$CONF" -daemon
|
||||
echo -n "Waiting for bitcoind"
|
||||
for _ in $(seq 1 60); do
|
||||
if bcli getblockchaininfo >/dev/null 2>&1; then
|
||||
echo " ready"
|
||||
break
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 0.5
|
||||
done
|
||||
fi
|
||||
|
||||
# ─── Create / load wallet ────────────────────────────────────────────────────
|
||||
if ! bcli -rpcwallet="$WALLET" getwalletinfo >/dev/null 2>&1; then
|
||||
bcli loadwallet "$WALLET" >/dev/null 2>&1 || bcli createwallet "$WALLET" >/dev/null
|
||||
fi
|
||||
|
||||
# ─── Mine initial blocks ─────────────────────────────────────────────────────
|
||||
BLOCKS=$(bcli getblockcount)
|
||||
if [[ $BLOCKS -lt $INITIAL_BLOCKS ]]; then
|
||||
NEED=$(( INITIAL_BLOCKS - BLOCKS ))
|
||||
ADDR=$(bcli -rpcwallet="$WALLET" getnewaddress "" bech32)
|
||||
bcli generatetoaddress "$NEED" "$ADDR" >/dev/null
|
||||
echo "Mined $NEED blocks (now at $(bcli getblockcount))"
|
||||
fi
|
||||
|
||||
# ─── Print descriptor for stealth-cli ─────────────────────────────────────────
|
||||
DESC=$(bcli -rpcwallet="$WALLET" listdescriptors \
|
||||
| grep -o '"desc":"[^"]*"' \
|
||||
| grep '/0/\*' \
|
||||
| grep -v 'internal' \
|
||||
| head -1 \
|
||||
| sed 's/"desc":"//;s/"$//')
|
||||
|
||||
COOKIE="$DATADIR/regtest/.cookie"
|
||||
|
||||
echo ""
|
||||
echo "Regtest ready."
|
||||
echo ""
|
||||
echo "Descriptor:"
|
||||
echo " $DESC"
|
||||
echo ""
|
||||
echo "Run:"
|
||||
echo " cargo run --bin stealth-cli -- scan \\"
|
||||
echo " --descriptor '$DESC' \\"
|
||||
echo " --rpc-url http://127.0.0.1:18443 \\"
|
||||
echo " --rpc-cookie '$COOKIE' \\"
|
||||
echo " --format text"
|
||||
Reference in New Issue
Block a user