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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ dist/
.qwen
**/__pycache__/
target/
Cargo.lock
Cargo.lock
bitcoin.conf
.bitcoin-regtest
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ members = [
"model",
"bitcoincore",
"engine",
"cli",
]
resolver = "2"

Expand Down
50 changes: 26 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Copy the example config:

```bash
cp bitcoin.conf.example bitcoin.conf
```
backend/script/config.ini
```

### 3. Development setup (regtest)

A regtest environment is provided for development and reproducible testing of heuristics.
### 3. Start regtest and fund a wallet

```bash
cd backend/script
./setup.sh
./scripts/setup.sh
```

### 4. Generate sample transactions
This starts `bitcoind` in regtest mode, creates a wallet, mines initial blocks,
and prints the descriptor and a ready-to-use `stealth-cli` command.

```bash
python3 reproduce.py
```
Use `./scripts/setup.sh --fresh` to wipe the chain and start from genesis.

### 5. Start backend
### 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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions bitcoin.conf.example
Comment thread
LORDBABUINO marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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 changes: 21 additions & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 changes: 250 additions & 0 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading
Loading