Two CRIT findings from the 2026-05-12 Opus audit. Both are mainnet-blocking against the aldabra-mainnet container. CRIT-1 — cap-bypass via unsigned-build → sign_partial → submit chain. Previously `wallet_send` / `wallet_mint` / `wallet_mint_cip68_nft` / `wallet_script_spend` enforced `max_send_lovelace`, but the unsigned- build tools + `wallet_sign_partial` + `wallet_submit_signed_tx` did not. A prompt-injection that walked the cold-signer chain could drain the wallet past the cap with zero policy enforcement. Fix: - `wallet_send_unsigned` / `wallet_mint_unsigned` / `wallet_plutus_mint_unsigned` now enforce the cap on the user- supplied destination lovelace, mirroring their signed equivalents. All three gain a `force: bool` arg with `#[serde(default)]`. - `wallet_sign_partial` and `wallet_submit_signed_tx` decode the Conway tx CBOR, sum lovelace across every output whose address is NOT this wallet's own primary address, and enforce the cap on that total. Both gain `force: bool`. The chokepoint covers cold-signed multi-sig flows and any hand-built CBOR the daemon would otherwise blindly sign or submit. - New free fn `sum_non_self_lovelace` is the unit-testable core of the chokepoint logic; `enforce_cap_on_cbor` wraps it. - The sum uses `try_fold` + `checked_add` (NOT `.sum::<u64>()`) so a crafted CBOR that overflows `u64::MAX` fails the check instead of wrapping silently in release builds. CRIT-2 — path traversal via `reference_script_path` and `policy_cbor_path`. Previously the tools called `std::fs::read_to_ string(p)` on any path the LLM passed. The MCP daemon runs as the same user that owns `$ALDABRA_DATA/mnemonic.age` / `$ALDABRA_DATA/root-xprv.age`. Decode-error messages included the hex_decode position offset — a small but real information leak about non-hex file structure. Fix: - New `Config::safe_reads_root` field (default `$ALDABRA_DATA/scripts/`, override via `ALDABRA_SAFE_READS_ROOT` env or TOML). - New `assert_inside_sandbox` helper canonicalize()s both the root and the user-supplied path, then enforces `starts_with`. Rejects outside-root paths, `..`-traversal, and nonexistent paths with generic messages. - Hardlink-rejection: post-canonicalize, stat the file and refuse if `nlink > 1`. `canonicalize` resolves symlinks but NOT hardlinks (a hardlink IS the file — same inode, different directory entry), so without this check an attacker with daemon-uid write access could plant a hardlink to the encrypted key blob inside the sandbox and exfiltrate bytes through the read path. - `resolve_ref_script_bytes` + `resolve_policy_cbor_bytes` + the `resolve_validator_required` wrapper used by all 5 escrow spend tools take `&Path` and route through the sandbox. - Error messages on hex_decode failures no longer carry the path string or byte-offset position — return a constant "contents are not valid hex" instead. - `main.rs` creates the sandbox root with 0o700 perms at startup if missing. chmod errors are surfaced (not swallowed) so a broken filesystem doesn't silently fall back to umask 0o755. - README documents the new `ALDABRA_SAFE_READS_ROOT` env var alongside `ALDABRA_MAX_SEND_LOVELACE` (also previously undocumented). Tests (243 → 253, +10): - 5 sandbox tests: accept-inside, reject-outside, reject-dotdot, reject-nonexistent, reject-hardlink. - 1 non-hex regression: constant message (no byte-offset leak). - 3 cap tests: self-send → 0 non-self total, outbound counts, overflow → Err (regression for the prompt-injection `u64::MAX` wraparound attempt). - 1 garbage-CBOR test: clean error. No new clippy warnings, no new fmt drift, `cargo audit` unchanged (0 CVEs, 2 transitive unmaintained warnings). Adversarial review of the first draft (3 Opus reviewers) caught the u64 overflow, the hardlink bypass, and the swallowed chmod error.
178 lines
6.6 KiB
Rust
178 lines
6.6 KiB
Rust
//! aldabra — MCP server entry point.
|
|
//!
|
|
//! Speaks MCP over stdio. Any MCP client (e.g. Claude Code)
|
|
//! launches this as a subprocess and gets a wallet's worth of tools.
|
|
//!
|
|
//! ## Phase 1 tools (target — server wiring lands in 1.7)
|
|
//!
|
|
//! - `wallet.address` — derived CIP-1852 base address
|
|
//! - `wallet.balance` — ADA + native-asset balance via chain backend
|
|
//! - `wallet.utxos` — list UTXOs at the wallet address
|
|
//! - `wallet.network` — configured network selector
|
|
//!
|
|
//! ## Phase 2-4 tools
|
|
//!
|
|
//! See `ROADMAP.md` at the repo root.
|
|
//!
|
|
//! ## Logging
|
|
//!
|
|
//! Stderr only — stdout is the MCP transport, must stay clean.
|
|
|
|
mod bootstrap;
|
|
mod config;
|
|
mod tools;
|
|
|
|
use std::process::ExitCode;
|
|
|
|
use anyhow::Result;
|
|
use rmcp::{transport::stdio, ServiceExt};
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
use crate::config::Config;
|
|
use crate::tools::WalletService;
|
|
|
|
#[tokio::main]
|
|
async fn main() -> ExitCode {
|
|
tracing_subscriber::fmt()
|
|
.with_writer(std::io::stderr)
|
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
|
.init();
|
|
|
|
match run().await {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(e) => {
|
|
tracing::error!("{e:#}");
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run() -> Result<()> {
|
|
let cfg = Config::load()?;
|
|
tracing::info!(
|
|
network = ?cfg.network,
|
|
koios = %cfg.koios_base,
|
|
koios_bearer_set = cfg.koios_bearer.is_some(),
|
|
account = cfg.account,
|
|
index = cfg.index,
|
|
data_dir = %cfg.data_dir.display(),
|
|
safe_reads_root = %cfg.safe_reads_root.display(),
|
|
"aldabra starting"
|
|
);
|
|
|
|
// CRIT-2 audit fix (2026-05-12): make sure the sandbox root exists
|
|
// and is daemon-only readable. Tools use canonicalize() against
|
|
// this dir to validate `*_path` args, which requires the dir to
|
|
// be a real directory on disk. Idempotent: create_dir_all is a
|
|
// no-op when the dir already exists.
|
|
//
|
|
// Adversarial-review fix (2026-05-12): chmod is `?`'d not swallowed.
|
|
// If the filesystem refuses chmod (noexec, selinux, broken mount),
|
|
// we'd otherwise silently fall back to the umask default (commonly
|
|
// 0o755) — making the security comment "daemon-only readable" a
|
|
// lie. Fail loudly instead so the operator sees + investigates.
|
|
if !cfg.safe_reads_root.exists() {
|
|
std::fs::create_dir_all(&cfg.safe_reads_root).map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"create safe_reads_root {}: {e}",
|
|
cfg.safe_reads_root.display()
|
|
)
|
|
})?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&cfg.safe_reads_root, std::fs::Permissions::from_mode(0o700))
|
|
.map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"chmod 0o700 safe_reads_root {}: {e}",
|
|
cfg.safe_reads_root.display()
|
|
)
|
|
})?;
|
|
}
|
|
}
|
|
|
|
// CLI mode flags — all out-of-band, before the MCP transport
|
|
// takes over stdio.
|
|
//
|
|
// `--generate-mnemonic` — print a fresh phrase, exit. No disk write.
|
|
// `--bootstrap` — paste an existing phrase, encrypt, derive.
|
|
// `--bootstrap-new` — generate, display, encrypt, derive (one shot).
|
|
// `--bootstrap-from-xprv` — paste a root_xsk1... bech32 (cnode root.prv,
|
|
// cardano-address output), encrypt, derive.
|
|
// Power-user import path for keys that came
|
|
// from outside the BIP-39 mnemonic flow.
|
|
// (none) — load existing, start MCP server.
|
|
let args: Vec<String> = std::env::args().collect();
|
|
let generate_only = args.iter().any(|a| a == "--generate-mnemonic");
|
|
let bootstrap_only = args.iter().any(|a| a == "--bootstrap");
|
|
let bootstrap_new = args.iter().any(|a| a == "--bootstrap-new");
|
|
let bootstrap_from_xprv = args.iter().any(|a| a == "--bootstrap-from-xprv");
|
|
|
|
if generate_only {
|
|
bootstrap::print_fresh_mnemonic()?;
|
|
return Ok(());
|
|
}
|
|
|
|
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
|
|
let xprv_path = bootstrap::root_xprv_path(&cfg.data_dir);
|
|
let any_key_exists = mnemonic_path.exists() || xprv_path.exists();
|
|
|
|
// L-2 audit fix: scope `root` to a block so its XPrv drops + wipes
|
|
// as soon as we've extracted the keys we need.
|
|
let (payment_key, stake_key, address) = {
|
|
let root = if bootstrap_new {
|
|
bootstrap::generate_and_save_root_key(&cfg.data_dir)?
|
|
} else if bootstrap_from_xprv {
|
|
bootstrap::import_root_xprv(&cfg.data_dir)?
|
|
} else if any_key_exists || bootstrap_only {
|
|
// Loads existing (or runs the mnemonic-paste flow on
|
|
// first-run with --bootstrap). load_or_create_root_key
|
|
// itself picks between mnemonic.age and root-xprv.age.
|
|
bootstrap::load_or_create_root_key(&cfg.data_dir)?
|
|
} else {
|
|
anyhow::bail!(
|
|
"no key at {}. run one of:\n \
|
|
`aldabra --bootstrap` (paste existing 24-word phrase)\n \
|
|
`aldabra --bootstrap-new` (generate a fresh wallet)\n \
|
|
`aldabra --bootstrap-from-xprv` (paste a root_xsk1... bech32)",
|
|
cfg.data_dir.display()
|
|
);
|
|
};
|
|
|
|
let address =
|
|
aldabra_core::derive_base_address(&root, cfg.network, cfg.account, cfg.index)?;
|
|
let payment_key = aldabra_core::derive_payment_key(&root, cfg.account, cfg.index);
|
|
let stake_key = aldabra_core::derive_stake_key(&root, cfg.account);
|
|
(payment_key, stake_key, address)
|
|
// root drops here — XPrv::Drop wipes the 96 bytes
|
|
};
|
|
tracing::info!(%address, "derived base address");
|
|
|
|
if bootstrap_only || bootstrap_new {
|
|
eprintln!("aldabra: bootstrap complete. address = {address}");
|
|
return Ok(());
|
|
}
|
|
|
|
// Hand off to the MCP server. From this point on stdin/stdout
|
|
// belong to the JSON-RPC transport — no more eprintln-prompting.
|
|
let service = WalletService::new(
|
|
cfg.network,
|
|
address,
|
|
cfg.koios_base,
|
|
cfg.koios_bearer,
|
|
payment_key,
|
|
stake_key,
|
|
cfg.max_send_lovelace,
|
|
cfg.data_dir.clone(),
|
|
cfg.safe_reads_root.clone(),
|
|
);
|
|
let server = service
|
|
.serve(stdio())
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("rmcp serve failed: {e}"))?;
|
|
server
|
|
.waiting()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("rmcp wait failed: {e}"))?;
|
|
Ok(())
|
|
}
|