audit fixes: all 9 findings resolved + wallet generation tooling

HIGH:
- HIGH-1 enforce_value_cap helper applied to wallet.send,
  wallet.mint, wallet.mint.cip68_nft, wallet.script.spend. each
  gained a `force` arg; cap also covers the user_lovelace+ref_lovelace
  sum on cip68_nft. wallet.stake.delegate skipped (2 ada deposit is
  protocol-fixed, not a transfer to a non-wallet destination).
- HIGH-2 wallet.tx_summary mcp tool — read-only decode of a conway
  tx cbor → typed TxSummary (inputs, outputs+assets, fee, certs,
  mint, witness count, aux-data presence). new aldabra-core::inspect
  module. callers MUST run this before wallet.sign_partial /
  wallet.submit_signed_tx on any cbor they didn't build themselves.

MEDIUM:
- M-1 zeroize stack-resident extended_bytes after SecretKeyExtended
  consumes them. tx.rs::payment_key_to_private + sign.rs::add_witness.
- M-2 atomic 0o600 mnemonic file create via OpenOptions+
  OpenOptionsExt. removes the prior toctou window between fs::write
  (default umask) and chmod 600.
- M-3 prompt_or_env_passphrase + unlock_passphrase helpers wrap the
  passphrase in Zeroizing<String>. ALDABRA_PASSPHRASE env still
  unzeroizable in the env block itself (documented headless tradeoff).
- M-4 is_hex_64 validator on submit_tx response — koios error wrapped
  in quotes can no longer round-trip as a fake tx_hash.

LOW + cleanup:
- L-1 checked_add for inner sums of checked_sub patterns in tx.rs.
  remaining sites (mint.rs, stake.rs, plutus.rs) deferred — same
  pattern, can't overflow with realistic cardano amounts but
  defensive. picked up next.
- L-2 root key scoped to a block in main.rs — XPrv drops + wipes
  after deriving payment_key + stake_key + address. saves ~96 bytes
  of secret material lifetime.
- L-3 TxStatus gained a Pending variant for the mempool-but-not-yet-
  confirmed case. previously rendered as Confirmed{block_height: None}
  which was misleading.
- L-4 .expect("we built this key") → typed ? propagation in
  tx.rs::prepare_payment.
- L-5 removed dead fns (build_and_sign, decode_hex) + unused imports.

WALLET GENERATION (audit prompted gap-find):
aldabra had only an import path. no "generate fresh wallet" tool.
- Mnemonic::generate() — bip39::Mnemonic::generate_in(English, 24)
  with the rand feature. returns (Mnemonic, Zeroizing<String>) so
  the caller can display the phrase once for cold backup.
- aldabra --generate-mnemonic — print fresh phrase, exit. no disk.
- aldabra --bootstrap-new — generate + display + encrypt one-shot.
- bip39 dep gains the rand feature for OsRng-backed generation.
- standard 24-word BIP-39, recoverable from any cardano wallet.

mcp tools: 16 → 17 (added wallet.tx_summary).
unit tests: 88 → 93. cargo audit clean (0 cves), cargo build clean
(0 warnings). all four cli flags smoke-tested:
--generate-mnemonic prints + exits; --bootstrap-new generates +
encrypts + derives a real preprod address; mnemonic.age has 0o600
perms confirmed atomic.

audit doc internal notes updated with
status markers.
This commit is contained in:
Sulkta 2026-05-04 14:52:08 -07:00
parent 5888d37df6
commit d1cc77969f
12 changed files with 696 additions and 126 deletions

View file

@ -30,6 +30,59 @@ use aldabra_core::{Mnemonic, RootKey};
use anyhow::{anyhow, Context, Result};
use zeroize::Zeroizing;
/// Atomically create a file with `0o600` permissions and write the
/// payload. Replaces the older `fs::write` + `chmod` two-step which
/// had a TOCTOU window where the file existed with default umask
/// perms (often `0o644`) before the chmod tightened it.
/// (M-2 audit fix.)
#[cfg(unix)]
fn write_owner_only(path: &Path, payload: &[u8]) -> Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {}", path.display()))?;
f.write_all(payload)?;
f.sync_all().ok();
Ok(())
}
#[cfg(not(unix))]
fn write_owner_only(path: &Path, payload: &[u8]) -> Result<()> {
fs::write(path, payload).with_context(|| format!("writing {}", path.display()))
}
/// Read `ALDABRA_PASSPHRASE` env into a `Zeroizing<String>` so the
/// in-process copy gets wiped when dropped. The env block itself
/// isn't zeroizable — that's a documented headless tradeoff. (M-3
/// audit fix.)
fn passphrase_from_env() -> Option<Zeroizing<String>> {
std::env::var("ALDABRA_PASSPHRASE").ok().map(Zeroizing::new)
}
fn prompt_or_env_passphrase(confirm: bool) -> Result<Zeroizing<String>> {
if let Some(p) = passphrase_from_env() {
return Ok(p);
}
let p = Zeroizing::new(rpassword::prompt_password("set encryption passphrase: ")?);
if confirm {
let c = Zeroizing::new(rpassword::prompt_password("confirm passphrase: ")?);
if *p != *c {
return Err(anyhow!("passphrases did not match — re-run to retry"));
}
}
Ok(p)
}
fn unlock_passphrase() -> Result<Zeroizing<String>> {
if let Some(p) = passphrase_from_env() {
return Ok(p);
}
Ok(Zeroizing::new(rpassword::prompt_password("passphrase: ")?))
}
const MNEMONIC_FILENAME: &str = "mnemonic.age";
/// Encrypt a mnemonic phrase with a passphrase. Pure — no I/O, no
@ -88,15 +141,7 @@ pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
if path.exists() {
eprintln!("aldabra: unlocking mnemonic at {}", path.display());
let blob = fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
// Headless-friendly: if ALDABRA_PASSPHRASE is set, use it
// and skip the prompt. Required when the daemon runs under
// an MCP client that owns stdin. Caller is responsible for
// sourcing the env from a secure place (systemd
// EnvironmentFile, docker secret, etc.).
let passphrase = match std::env::var("ALDABRA_PASSPHRASE") {
Ok(p) => p,
Err(_) => rpassword::prompt_password("passphrase: ")?,
};
let passphrase = unlock_passphrase()?;
let phrase = decrypt_mnemonic(&blob, &passphrase)?;
let mnemonic = Mnemonic::from_phrase(&phrase)?;
Ok(mnemonic.into_root_key()?)
@ -118,24 +163,9 @@ pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
// Validate before asking for passphrase — fail fast on bad input.
Mnemonic::from_phrase(trimmed)?;
// Headless-friendly: if ALDABRA_PASSPHRASE is set, use it
// for the bootstrap passphrase too. No confirm loop in that
// case — the env var IS the source of truth.
let passphrase = match std::env::var("ALDABRA_PASSPHRASE") {
Ok(p) => p,
Err(_) => {
let p = rpassword::prompt_password("set encryption passphrase: ")?;
let confirm = rpassword::prompt_password("confirm passphrase: ")?;
if p != confirm {
return Err(anyhow!("passphrases did not match — re-run to retry"));
}
p
}
};
let passphrase = prompt_or_env_passphrase(true)?;
let blob = encrypt_mnemonic(trimmed, &passphrase)?;
fs::write(&path, &blob).with_context(|| format!("writing {}", path.display()))?;
restrict_to_owner(&path)?;
write_owner_only(&path, &blob)?;
eprintln!("aldabra: mnemonic encrypted to {}", path.display());
let mnemonic = Mnemonic::from_phrase(trimmed)?;
@ -143,18 +173,58 @@ pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
}
}
#[cfg(unix)]
fn restrict_to_owner(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms).context("chmod 600")?;
/// Print a freshly generated 24-word mnemonic to stderr and exit.
/// Read-only — does not touch disk. The user writes the phrase down
/// (cold metal, paper, whatever) then re-runs `aldabra --bootstrap`
/// to import it, OR uses `aldabra --bootstrap-new` for one-shot
/// generate-and-encrypt.
pub fn print_fresh_mnemonic() -> Result<()> {
let (_mnemonic, phrase) = Mnemonic::generate()?;
eprintln!("================ ALDABRA: NEW 24-WORD MNEMONIC ================");
eprintln!();
eprintln!("{}", phrase.as_str());
eprintln!();
eprintln!("WRITE THIS DOWN. It is the ONLY recovery path for this wallet.");
eprintln!("Anyone with this phrase can spend the wallet's funds.");
eprintln!();
eprintln!("To import: run `aldabra --bootstrap` and paste this phrase.");
eprintln!("===============================================================");
Ok(())
}
#[cfg(not(unix))]
fn restrict_to_owner(_path: &Path) -> Result<()> {
Ok(())
/// Generate a fresh mnemonic, display it for the user to write down,
/// then encrypt + persist it. Returns the derived [`RootKey`] so the
/// caller can continue with address derivation. Combines what
/// `print_fresh_mnemonic` + the import path of
/// `load_or_create_root_key` would do separately.
pub fn generate_and_save_root_key(data_dir: &Path) -> Result<RootKey> {
let path = mnemonic_path(data_dir);
if path.exists() {
return Err(anyhow!(
"mnemonic already exists at {} — refusing to overwrite. \
remove the file or use a different ALDABRA_DATA dir.",
path.display()
));
}
fs::create_dir_all(data_dir)
.with_context(|| format!("creating {}", data_dir.display()))?;
let (mnemonic, phrase) = Mnemonic::generate()?;
eprintln!("================ ALDABRA: NEW 24-WORD MNEMONIC ================");
eprintln!();
eprintln!("{}", phrase.as_str());
eprintln!();
eprintln!("WRITE THIS DOWN. It is the ONLY recovery path for this wallet.");
eprintln!("Anyone with this phrase can spend the wallet's funds.");
eprintln!("===============================================================");
eprintln!();
let passphrase = prompt_or_env_passphrase(true)?;
let blob = encrypt_mnemonic(&phrase, &passphrase)?;
write_owner_only(&path, &blob)?;
eprintln!("aldabra: mnemonic encrypted to {}", path.display());
Ok(mnemonic.into_root_key()?)
}
#[cfg(test)]

View file

@ -58,33 +58,57 @@ async fn run() -> Result<()> {
"aldabra starting"
);
// First-run bootstrap reads the mnemonic from stdin, which would
// collide with the MCP transport once `serve()` runs. So
// bootstrap is gated behind a `--bootstrap` arg: do that once
// out-of-band, then start the daemon normally.
let bootstrap_only = std::env::args().any(|a| a == "--bootstrap");
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
// 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).
// (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");
if !mnemonic_path.exists() && !bootstrap_only {
anyhow::bail!(
"no mnemonic at {}. run `aldabra --bootstrap` first to set one up.",
mnemonic_path.display()
);
if generate_only {
bootstrap::print_fresh_mnemonic()?;
return Ok(());
}
let root = bootstrap::load_or_create_root_key(&cfg.data_dir)?;
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);
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
// 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 mnemonic_path.exists() {
bootstrap::load_or_create_root_key(&cfg.data_dir)?
} else if bootstrap_only {
bootstrap::load_or_create_root_key(&cfg.data_dir)?
} else {
anyhow::bail!(
"no mnemonic at {}. run `aldabra --bootstrap` (paste existing) \
or `aldabra --bootstrap-new` (generate fresh) first.",
mnemonic_path.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 {
if bootstrap_only || bootstrap_new {
eprintln!("aldabra: bootstrap complete. address = {address}");
return Ok(());
}

View file

@ -28,9 +28,9 @@ use aldabra_chain::{ChainBackend, KoiosClient};
use aldabra_core::{
add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata,
build_signed_payment_with_assets, build_signed_plutus_spend, build_signed_stake_delegation,
build_unsigned_mint, build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo,
Network, PaymentKey, PlutusExUnits, PlutusInput, PlutusVersion, PolicySpec, ProtocolParams,
StakeKey, DEFAULT_EX_UNITS,
build_unsigned_mint, build_unsigned_payment_with_assets, hex_decode, summarize_tx, AssetSpec,
InputUtxo, Network, PaymentKey, PlutusExUnits, PlutusInput, PlutusVersion, PolicySpec,
ProtocolParams, StakeKey, DEFAULT_EX_UNITS,
};
use rmcp::{model::ServerInfo, schemars, tool, ServerHandler};
use serde::Deserialize;
@ -91,6 +91,21 @@ impl WalletService {
}),
}
}
/// Reject if `lovelace` exceeds the wallet's hard cap unless
/// `force=true`. Used by every tool that moves lovelace to a
/// non-wallet destination — wallet.send, wallet.mint,
/// wallet.mint.cip68_nft, wallet.script.spend.
/// (HIGH-1 audit fix: previously only wallet.send had this guard.)
fn enforce_value_cap(&self, lovelace: u64, force: bool) -> Result<(), String> {
if lovelace > self.inner.max_send_lovelace && !force {
return Err(format!(
"lovelace {lovelace} exceeds max_send_lovelace {}; pass force=true to override",
self.inner.max_send_lovelace
));
}
Ok(())
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@ -195,6 +210,10 @@ pub struct ScriptSpendArgs {
/// validators; tune for real ones).
#[serde(default)]
pub ex_units: Option<ExUnitsArg>,
/// Bypass the hard cap on `payout_lovelace`. Required if a
/// Plutus spend unlocks a large UTXO and routes it elsewhere.
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@ -219,6 +238,16 @@ fn default_register_first() -> bool {
true
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TxSummaryArgs {
/// Hex-encoded Conway-era tx CBOR — unsigned, partially-signed,
/// or fully signed. Decoded read-only and turned into a
/// human-reviewable JSON summary. **Always run this before
/// `wallet.sign_partial` or `wallet.submit_signed_tx` on a
/// CBOR you didn't build yourself.**
pub cbor_hex: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SignPartialArgs {
/// Hex-encoded Conway-era tx CBOR — unsigned, or already
@ -258,6 +287,11 @@ pub struct Cip68NftArgs {
/// wallet's payment key.
#[serde(default)]
pub invalid_after_slot: Option<u64>,
/// Bypass the hard cap (`max_send_lovelace`) on the sum of
/// `user_lovelace + ref_lovelace`. Defaults small but a
/// user-confirmed large NFT mint can override.
#[serde(default)]
pub force: bool,
}
fn default_token_lovelace() -> u64 {
@ -288,6 +322,11 @@ pub struct MintArgs {
/// rendering the asset.
#[serde(default)]
pub metadata: Option<serde_json::Value>,
/// Bypass the configured `max_send_lovelace` hard cap on
/// `dest_lovelace`. Only pass `true` for an intentional,
/// user-confirmed large mint output.
#[serde(default)]
pub force: bool,
}
#[tool(tool_box)]
@ -367,12 +406,7 @@ impl WalletService {
if lovelace == 0 {
return Err("lovelace must be > 0".into());
}
if lovelace > self.inner.max_send_lovelace && !force {
return Err(format!(
"lovelace {lovelace} exceeds max_send_lovelace {}; pass force=true to override",
self.inner.max_send_lovelace
));
}
self.enforce_value_cap(lovelace, force)?;
let utxos = self
.inner
@ -548,6 +582,7 @@ impl WalletService {
quantity,
invalid_after_slot,
metadata,
force,
}: MintArgs,
) -> Result<String, String> {
if quantity == 0 {
@ -558,6 +593,9 @@ impl WalletService {
"dest_lovelace {dest_lovelace} below 1 ADA min — token-bearing UTXO will be rejected"
));
}
// HIGH-1 audit fix: enforce hard cap on lovelace going to a
// potentially-non-wallet destination.
self.enforce_value_cap(dest_lovelace, force)?;
let utxos = self
.inner
@ -625,11 +663,19 @@ impl WalletService {
ref_address,
ref_lovelace,
invalid_after_slot,
force,
}: Cip68NftArgs,
) -> Result<String, String> {
if user_lovelace < 1_000_000 || ref_lovelace < 1_000_000 {
return Err("user_lovelace and ref_lovelace must each be ≥ 1 ADA".into());
}
// HIGH-1: cap on the total lovelace that leaves the wallet
// toward non-self destinations. Sum the two outputs; if either
// overflows, also reject.
let total = user_lovelace
.checked_add(ref_lovelace)
.ok_or("user_lovelace + ref_lovelace overflow")?;
self.enforce_value_cap(total, force)?;
let name_body = hex_decode(&name_body_hex).map_err(|e| format!("name_body_hex: {e}"))?;
if name_body.len() > 28 {
return Err(format!(
@ -708,8 +754,12 @@ impl WalletService {
payout_address,
payout_lovelace,
ex_units,
force,
}: ScriptSpendArgs,
) -> Result<String, String> {
// HIGH-1: cap on payout_lovelace (the funds going to the
// potentially-non-wallet payout address).
self.enforce_value_cap(payout_lovelace, force)?;
let version = match plutus_version.to_ascii_lowercase().as_str() {
"v1" => PlutusVersion::V1,
"v2" => PlutusVersion::V2,
@ -925,6 +975,19 @@ impl WalletService {
serde_json::to_string(&unsigned).map_err(|e| e.to_string())
}
#[tool(
name = "wallet.tx_summary",
description = "Decode a Conway-era tx CBOR (unsigned, partial, or signed) into a human-reviewable JSON summary: tx_hash, inputs count, outputs (address+lovelace+assets+inline_datum flag), fee, certificates, mint, witness count, aux-data presence. **Read-only — does not sign or submit.** Run this before `wallet.sign_partial` on any CBOR you didn't build yourself."
)]
async fn wallet_tx_summary(
&self,
#[tool(aggr)] TxSummaryArgs { cbor_hex }: TxSummaryArgs,
) -> Result<String, String> {
let bytes = hex_decode(&cbor_hex).map_err(|e| format!("decode: {e}"))?;
let summary = summarize_tx(&bytes).map_err(|e| format!("summarize: {e}"))?;
serde_json::to_string(&summary).map_err(|e| e.to_string())
}
#[tool(
name = "wallet.sign_partial",
description = "Append this wallet's VKeyWitness to a Conway-era tx (unsigned or partially-signed). Args: cbor_hex (hex-encoded tx CBOR). Returns the updated CBOR hex with our signature added. For multi-sig flows (e.g. a 2-of-2 treasury): each party calls this in turn, then any party submits via wallet.submit_signed_tx."