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 memory/spec-aldabra-audit-2026-05-04.md updated with
status markers.
This commit is contained in:
Cobb 2026-05-04 14:52:08 -07:00
parent 7ea4c4cd33
commit f17479ab92
12 changed files with 696 additions and 126 deletions

1
Cargo.lock generated
View file

@ -210,6 +210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes",
"rand 0.8.6",
"rand_core 0.6.4",
"serde",
"unicode-normalization",

View file

@ -52,7 +52,8 @@ pallas-network = "0.32"
# (CIP-3). Already pulled in transitively by
# ed25519-bip32; declared here so we can use pbkdf2 + Sha512
# directly in aldabra-core.
bip39 = "2"
# `rand` feature pulls in OsRng-backed Mnemonic::generate_in for new-wallet flows.
bip39 = { version = "2", features = ["rand"] }
ed25519-bip32 = "0.4"
cryptoxide = "0.4"

View file

@ -130,6 +130,13 @@ fn parse_u64(s: &str, field: &str) -> Result<u64, ChainError> {
.map_err(|e| ChainError::Decode(format!("{field}: {e} (got {s:?})")))
}
/// True iff `s` is exactly 64 hex chars — what a Cardano tx hash must
/// look like. Used by `submit_tx` to validate the response wasn't an
/// error message wrapped in quotes.
fn is_hex_64(s: &str) -> bool {
s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
}
fn asset_key(policy_id: &str, asset_name_hex: &str) -> String {
let mut k = String::with_capacity(policy_id.len() + asset_name_hex.len());
k.push_str(policy_id);
@ -205,18 +212,30 @@ impl ChainBackend for KoiosClient {
.await
.map_err(|e| ChainError::Decode(e.to_string()))?;
// Koios returns the tx hash as a quoted JSON string. Strip the
// surrounding quotes if present.
Ok(body.trim().trim_matches('"').to_string())
// surrounding quotes if present, then validate the result is
// exactly 64 hex chars.
// M-4 audit fix: previously a quoted error message would
// round-trip as a fake tx_hash.
let hash = body.trim().trim_matches('"').to_string();
if !is_hex_64(&hash) {
return Err(ChainError::Decode(format!(
"submittx returned non-hash response: {body:?}"
)));
}
Ok(hash)
}
async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError> {
let body = TxHashesBody { tx_hashes: vec![tx_hash] };
let raw: Vec<KoiosTxInfo> = self.post_json("tx_info", &body).await?;
match raw.into_iter().next() {
Some(info) => Ok(TxStatus::Confirmed {
block_height: info.block_height,
epoch: info.epoch_no,
}),
Some(info) => match info.block_height {
Some(h) => Ok(TxStatus::Confirmed {
block_height: h,
epoch: info.epoch_no,
}),
None => Ok(TxStatus::Pending),
},
None => Ok(TxStatus::NotFound),
}
}
@ -342,6 +361,16 @@ mod tests {
assert_eq!(assets.get("ee0a1234deadbeef"), Some(&123));
}
#[test]
fn is_hex_64_validates_tx_hash_shape() {
assert!(is_hex_64(&"a".repeat(64)));
assert!(is_hex_64(&"ABCDef0123456789".repeat(4)));
assert!(!is_hex_64(&"a".repeat(63)), "wrong length");
assert!(!is_hex_64(&"a".repeat(65)), "wrong length");
assert!(!is_hex_64(&"z".repeat(64)), "non-hex chars");
assert!(!is_hex_64("invalid tx"), "error message");
}
#[test]
fn parse_u64_rejects_garbage() {
let err = parse_u64("not-a-number", "test").unwrap_err();
@ -384,15 +413,19 @@ mod tests {
#[test]
fn tx_status_serializes_with_tag() {
let confirmed = TxStatus::Confirmed {
block_height: Some(100),
block_height: 100,
epoch: Some(5),
};
let json = serde_json::to_string(&confirmed).unwrap();
assert!(json.contains("\"status\":\"confirmed\""));
assert!(json.contains("\"block_height\":100"));
let pending = TxStatus::NotFound;
let pending = TxStatus::Pending;
let json = serde_json::to_string(&pending).unwrap();
assert!(json.contains("\"status\":\"pending\""));
let nf = TxStatus::NotFound;
let json = serde_json::to_string(&nf).unwrap();
assert!(json.contains("\"status\":\"not_found\""));
}

View file

@ -65,13 +65,19 @@ pub struct Balance {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum TxStatus {
/// Confirmed on-chain. `block_height` and `epoch` are populated
/// when Koios returns them.
/// Confirmed on-chain with a block height.
Confirmed {
block_height: Option<u64>,
block_height: u64,
epoch: Option<u64>,
},
/// Not (yet) seen by the chain backend.
/// Koios returned a record but no block_height — the tx is in
/// the chain backend's mempool but not yet in a confirmed block.
/// (L-3 audit fix: previously this case was lumped in with
/// Confirmed and rendered as `Confirmed { block_height: None }`,
/// which is misleading.)
Pending,
/// Not seen by the chain backend (not in mempool, not confirmed,
/// possibly never submitted or rejected).
NotFound,
}

View file

@ -0,0 +1,328 @@
//! Decode + summarize a Conway-era tx CBOR for human review.
//!
//! Used by `wallet.tx_summary` (Phase 4 / audit HIGH-2 fix). Before
//! a caller hands a pre-built CBOR to `wallet.sign_partial` or
//! `wallet.submit_signed_tx`, they should pull a summary through here
//! and review what the tx actually does — what's being spent, where
//! funds are going, what's being minted, what certs are present.
//!
//! No I/O. Pure decode → typed summary. Caller serializes to JSON.
use pallas_primitives::conway::{
Certificate, PseudoTransactionOutput, StakeCredential, TransactionOutput, Tx, Value,
};
use pallas_primitives::Fragment;
use pallas_traverse::ComputeHash;
use serde::Serialize;
use crate::WalletError;
/// Top-level summary of a Conway-era transaction.
#[derive(Debug, Clone, Serialize)]
pub struct TxSummary {
/// Body hash (matches what witnesses sign). 64-char hex.
pub tx_hash: String,
/// Number of inputs being consumed.
pub num_inputs: usize,
/// One entry per output, in tx order.
pub outputs: Vec<OutputSummary>,
/// Fee in lovelace.
pub fee_lovelace: u64,
/// 0 = testnet header byte, 1 = mainnet header byte. None means
/// the field wasn't set (rare in practice).
pub network_id: Option<u8>,
/// `valid_from_slot` if set (tx invalid before this slot).
pub valid_from_slot: Option<u64>,
/// `invalid_from_slot` if set (tx invalid at-or-after this slot).
pub invalid_from_slot: Option<u64>,
/// Certificates in the tx body. Common: stake registration,
/// stake delegation, DRep ops.
pub certificates: Vec<CertificateSummary>,
/// Mint actions. Positive amounts mint, negative amounts burn.
pub mint: Vec<MintEntry>,
/// Number of `VKeyWitness` entries currently in the witness set.
/// 0 = unsigned. Each call to `wallet.sign_partial` adds one.
pub vkey_witness_count: usize,
/// Whether the tx body declares an `auxiliary_data_hash`. If true,
/// `auxiliary_data_present` says whether the actual aux data
/// rides along (a hash without data is malformed).
pub auxiliary_data_hash_set: bool,
pub auxiliary_data_present: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct OutputSummary {
/// Hex of the raw address bytes — caller can decode bech32 if they
/// want a friendlier render. Avoids embedding pallas-addresses
/// here.
pub address_hex: String,
pub lovelace: u64,
/// Native assets riding with this output. Empty for ADA-only.
pub assets: Vec<AssetEntry>,
/// True if the output carries an inline datum (Plutus / CIP-68).
pub has_inline_datum: bool,
/// True if the output declares a reference script.
pub has_reference_script: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct AssetEntry {
pub policy_id_hex: String,
pub asset_name_hex: String,
pub quantity: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct MintEntry {
pub policy_id_hex: String,
pub asset_name_hex: String,
/// Negative for burn.
pub quantity: i64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CertificateSummary {
StakeRegistration {
credential_hex: String,
},
StakeDeregistration {
credential_hex: String,
},
StakeDelegation {
credential_hex: String,
pool_id_hex: String,
},
/// Catch-all for cert types we haven't surfaced explicitly yet
/// (pool registration, DRep ops, Voltaire-era combos).
Other {
debug: String,
},
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
fn credential_hex(c: &StakeCredential) -> String {
match c {
StakeCredential::AddrKeyhash(h) => hex(h.as_ref()),
StakeCredential::ScriptHash(h) => hex(h.as_ref()),
}
}
fn cert_summary(c: &Certificate) -> CertificateSummary {
match c {
Certificate::StakeRegistration(cred) => CertificateSummary::StakeRegistration {
credential_hex: credential_hex(cred),
},
Certificate::StakeDeregistration(cred) => CertificateSummary::StakeDeregistration {
credential_hex: credential_hex(cred),
},
Certificate::StakeDelegation(cred, pool) => CertificateSummary::StakeDelegation {
credential_hex: credential_hex(cred),
pool_id_hex: hex(pool.as_ref()),
},
other => CertificateSummary::Other {
debug: format!("{other:?}"),
},
}
}
fn output_summary(out: &TransactionOutput) -> OutputSummary {
match out {
PseudoTransactionOutput::Legacy(legacy) => {
let (lovelace, assets) = decode_alonzo_value(&legacy.amount);
OutputSummary {
address_hex: hex(&legacy.address),
lovelace,
assets,
has_inline_datum: false,
has_reference_script: false,
}
}
PseudoTransactionOutput::PostAlonzo(po) => {
let has_inline = matches!(
po.datum_option.as_ref(),
Some(pallas_primitives::conway::PseudoDatumOption::Data(_))
);
let has_ref = po.script_ref.is_some();
let (lovelace, assets) = decode_conway_value(&po.value);
OutputSummary {
address_hex: hex(&po.address),
lovelace,
assets,
has_inline_datum: has_inline,
has_reference_script: has_ref,
}
}
}
}
fn decode_conway_value(v: &Value) -> (u64, Vec<AssetEntry>) {
match v {
Value::Coin(c) => (*c, vec![]),
Value::Multiasset(coin, multi) => {
let mut assets = Vec::new();
for (policy, names) in multi.iter() {
for (name, qty) in names.iter() {
let name_bytes: &[u8] = name.as_ref();
let qty_u64: u64 = (*qty).into();
assets.push(AssetEntry {
policy_id_hex: hex(policy.as_ref()),
asset_name_hex: hex(name_bytes),
quantity: qty_u64,
});
}
}
(*coin, assets)
}
}
}
fn decode_alonzo_value(v: &pallas_primitives::alonzo::Value) -> (u64, Vec<AssetEntry>) {
use pallas_primitives::alonzo::Value as AV;
match v {
AV::Coin(c) => (*c, vec![]),
AV::Multiasset(coin, multi) => {
let mut assets = Vec::new();
for (policy, names) in multi.iter() {
for (name, qty) in names.iter() {
let name_bytes: &[u8] = name.as_ref();
assets.push(AssetEntry {
policy_id_hex: hex(policy.as_ref()),
asset_name_hex: hex(name_bytes),
quantity: *qty,
});
}
}
(*coin, assets)
}
}
}
/// Decode + summarize a Conway-era tx CBOR. Caller serializes the
/// returned struct to JSON.
pub fn summarize_tx(cbor_bytes: &[u8]) -> Result<TxSummary, WalletError> {
let tx = Tx::decode_fragment(cbor_bytes)
.map_err(|e| WalletError::Derivation(format!("decode tx: {e}")))?;
let body = &tx.transaction_body;
let body_hash = body.compute_hash();
let outputs: Vec<OutputSummary> = body.outputs.iter().map(output_summary).collect();
let certificates: Vec<CertificateSummary> = body
.certificates
.as_ref()
.map(|c| c.iter().map(cert_summary).collect())
.unwrap_or_default();
let mut mint_entries: Vec<MintEntry> = Vec::new();
if let Some(mint) = body.mint.as_ref() {
for (policy, names) in mint.iter() {
for (name, qty) in names.iter() {
let name_bytes: &[u8] = name.as_ref();
mint_entries.push(MintEntry {
policy_id_hex: hex(policy.as_ref()),
asset_name_hex: hex(name_bytes),
quantity: i64::from(*qty),
});
}
}
}
let vkey_witness_count = tx
.transaction_witness_set
.vkeywitness
.as_ref()
.map(|w| w.len())
.unwrap_or(0);
let auxiliary_data_hash_set = body.auxiliary_data_hash.is_some();
let auxiliary_data_present = matches!(
tx.auxiliary_data,
pallas_codec::utils::Nullable::Some(_)
);
Ok(TxSummary {
tx_hash: hex(body_hash.as_ref()),
num_inputs: body.inputs.len(),
outputs,
fee_lovelace: body.fee,
network_id: body.network_id.as_ref().map(|n| match n {
pallas_primitives::conway::NetworkId::Testnet => 0u8,
pallas_primitives::conway::NetworkId::Mainnet => 1u8,
}),
valid_from_slot: body.validity_interval_start,
invalid_from_slot: body.ttl,
certificates,
mint: mint_entries,
vkey_witness_count,
auxiliary_data_hash_set,
auxiliary_data_present,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tx::{build_unsigned_payment, InputUtxo, ProtocolParams};
use crate::{Mnemonic, Network};
const ABANDON_ART: &str = concat!(
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon art",
);
fn fixture_unsigned_payment_cbor() -> Vec<u8> {
let root = Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap();
let change = crate::derive_base_address(&root, Network::Preprod, 0, 0).unwrap();
let to = crate::derive_base_address(&root, Network::Preprod, 0, 1).unwrap();
let utxos = vec![InputUtxo {
tx_hash_hex: "deadbeef".repeat(8),
output_index: 0,
lovelace: 100_000_000,
assets: Default::default(),
}];
let unsigned = build_unsigned_payment(
Network::Preprod,
&utxos,
&change,
&to,
10_000_000,
&ProtocolParams::default(),
)
.unwrap();
crate::tx::hex_decode(&unsigned.cbor_hex).unwrap()
}
#[test]
fn summarizes_simple_payment() {
let cbor = fixture_unsigned_payment_cbor();
let s = summarize_tx(&cbor).unwrap();
assert_eq!(s.tx_hash.len(), 64);
assert_eq!(s.num_inputs, 1);
// Send + change = 2 outputs.
assert_eq!(s.outputs.len(), 2);
// Recipient gets 10M.
assert!(s.outputs.iter().any(|o| o.lovelace == 10_000_000));
assert!(s.fee_lovelace > 0);
assert_eq!(s.vkey_witness_count, 0, "unsigned tx has no witnesses");
assert!(s.certificates.is_empty());
assert!(s.mint.is_empty());
}
#[test]
fn rejects_garbage_cbor() {
assert!(summarize_tx(b"not cbor").is_err());
}
}

View file

@ -33,10 +33,11 @@ use pallas_addresses::{
Network as PallasNetwork, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart,
};
use thiserror::Error;
use zeroize::ZeroizeOnDrop;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
pub mod cip68;
pub mod derive;
pub mod inspect;
pub mod metadata;
pub mod mint;
pub mod plutus;
@ -47,6 +48,7 @@ pub use cip68::{
build_cip68_datum_cbor, ft_asset_name, ref_nft_asset_name, user_nft_asset_name,
};
pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey};
pub use inspect::{summarize_tx, AssetEntry, CertificateSummary, MintEntry, OutputSummary, TxSummary};
// Stake address derivation lives directly on StakeKey — exported above.
pub use metadata::{build_cip25_aux_data, CIP25_LABEL};
pub use mint::{
@ -95,6 +97,27 @@ pub struct Mnemonic {
}
impl Mnemonic {
/// Generate a fresh 24-word mnemonic from the system random source.
/// Returns the typed [`Mnemonic`] (entropy stored, zeroized on drop)
/// **and** the phrase string for one-time display to the user.
/// The phrase is wrapped in [`Zeroizing`] so the caller doesn't
/// have to remember to wipe it.
pub fn generate() -> Result<(Self, Zeroizing<String>), WalletError> {
let bip = Bip39Mnemonic::generate_in(Language::English, 24)
.map_err(|e| WalletError::InvalidMnemonic(format!("generate failed: {e}")))?;
// bip39's Display impl emits the space-separated phrase. Pull
// it out into our own owned + zeroized string before bip drops.
let phrase = Zeroizing::new(bip.to_string());
let entropy_vec = bip.to_entropy();
let entropy: [u8; 32] = entropy_vec.try_into().map_err(|v: Vec<u8>| {
WalletError::InvalidMnemonic(format!(
"expected 32 entropy bytes for 24-word mnemonic, got {}",
v.len()
))
})?;
Ok((Self { entropy }, phrase))
}
/// Parse a 24-word English mnemonic, validating word count + checksum.
/// Drops the source phrase reference immediately after extracting
/// entropy.
@ -143,6 +166,11 @@ impl Mnemonic {
pbkdf2(&mut hmac, &self.entropy, 4096, &mut xprv_bytes);
let xprv = XPrv::normalize_bytes_force3rd(xprv_bytes);
// `xprv_bytes` was moved into normalize_bytes_force3rd, but
// the stack slot can still hold a copy depending on calling
// conventions / inlining. Defensive zeroize.
// (M-1 audit fix.)
xprv_bytes.zeroize();
Ok(RootKey { xprv })
}
}
@ -269,6 +297,25 @@ mod tests {
assert_eq!(m.entropy, [0u8; 32]);
}
#[test]
fn generate_produces_24_word_phrase() {
let (mnemonic, phrase) = Mnemonic::generate().expect("generate");
assert_eq!(phrase.split_whitespace().count(), 24);
// Round-trip: re-parse the generated phrase, confirm we land on
// the same entropy.
let reparsed = Mnemonic::from_phrase(&phrase).expect("re-parse own output");
assert_eq!(reparsed.entropy, mnemonic.entropy);
}
#[test]
fn generate_produces_distinct_phrases() {
let (a, _phrase_a) = Mnemonic::generate().unwrap();
let (b, _phrase_b) = Mnemonic::generate().unwrap();
// Astronomically unlikely to collide; if this ever fails the
// RNG source is broken.
assert_ne!(a.entropy, b.entropy);
}
#[test]
fn derives_root_key_from_canonical_mnemonic() {
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();

View file

@ -17,9 +17,7 @@
//! follow-ups. ExUnits today come from the caller.
use bech32::FromBase32;
use pallas_codec::minicbor;
use pallas_crypto::hash::Hash;
use pallas_primitives::Fragment;
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction};
use crate::sign::add_witness;
@ -96,20 +94,6 @@ fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
Ok(Hash::<32>::new(out))
}
fn decode_hex(s: &str) -> Result<Vec<u8>, WalletError> {
if s.len() % 2 != 0 {
return Err(WalletError::Derivation("hex string odd length".into()));
}
let mut out = Vec::with_capacity(s.len() / 2);
for i in (0..s.len()).step_by(2) {
out.push(
u8::from_str_radix(&s[i..i + 2], 16)
.map_err(|_| WalletError::Derivation(format!("invalid hex: {s}")))?,
);
}
Ok(out)
}
fn network_id_for(network: Network) -> u8 {
match network {
Network::Mainnet => 1,
@ -363,6 +347,7 @@ mod tests {
.expect("plutus spend builds + signs");
assert!(cbor.len() > 200);
use pallas_primitives::Fragment;
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
.expect("decode plutus spend cbor");
// Inputs include the locked UTXO + collateral.

View file

@ -28,6 +28,7 @@ use pallas_crypto::key::ed25519::SecretKeyExtended;
use pallas_primitives::conway::{Tx, VKeyWitness};
use pallas_primitives::Fragment;
use pallas_traverse::ComputeHash;
use zeroize::Zeroize;
use crate::{PaymentKey, WalletError};
@ -46,9 +47,13 @@ pub fn add_witness(
// encoder).
let body_hash = tx.transaction_body.compute_hash();
let extended_bytes: [u8; 64] = payment_key.xprv().extended_secret_key();
// M-1 audit fix: stack copy gets zeroized after from_bytes
// consumes it.
let mut extended_bytes: [u8; 64] = payment_key.xprv().extended_secret_key();
let secret = SecretKeyExtended::from_bytes(extended_bytes)
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?;
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")));
extended_bytes.zeroize();
let secret = secret?;
let signature = secret.sign(body_hash.as_ref());
let pubkey = secret.public_key();

View file

@ -48,6 +48,7 @@ use pallas_crypto::key::ed25519::SecretKeyExtended;
use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, StagingTransaction};
use pallas_wallet::PrivateKey;
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
use crate::{Network, PaymentKey, WalletError};
@ -280,11 +281,20 @@ fn hex_decode_32(s: &str) -> Result<[u8; 32], WalletError> {
/// Convert a [`PaymentKey`] into a `pallas-wallet::PrivateKey` so
/// `BuiltTransaction::sign` can consume it. The XPrv's first 64
/// bytes are the extended secret; we reuse them directly.
///
/// **M-1 audit fix**: defensive zeroize of the stack-resident
/// extended-secret bytes after `from_bytes` consumes them. The
/// SecretKeyExtended itself zeroizes on drop (pallas-crypto handles
/// that); this just covers the local stack copy that lingers between
/// `extended_secret_key()` returning and `from_bytes` taking it by
/// value.
fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> {
let xprv: &XPrv = payment.xprv();
let extended: [u8; 64] = xprv.extended_secret_key();
let mut extended: [u8; 64] = xprv.extended_secret_key();
let secret = SecretKeyExtended::from_bytes(extended)
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?;
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")));
extended.zeroize();
let secret = secret?;
Ok(PrivateKey::Extended(secret))
}
@ -397,18 +407,6 @@ fn build_unsigned_bytes(
Ok(built.tx_bytes.0)
}
fn build_and_sign(
staging: StagingTransaction,
private: PrivateKey,
) -> Result<Vec<u8>, WalletError> {
let built = staging
.build_conway_raw()
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?;
let signed = built
.sign(private)
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
Ok(signed.tx_bytes.0)
}
/// Internal helper — runs the two-pass fee refinement and returns
/// the final `BuiltTransaction` plus a `PaymentSummary` describing
@ -495,12 +493,13 @@ fn prepare_payment(
// worth of lovelace into change in that case.
let change_must_exist = !change_assets.is_empty();
let (final_fee, final_change) = match total_in_lovelace.checked_sub(lovelace + real_fee) {
// L-1 audit fix: checked_add for the inner sum so the outer
// checked_sub is fully defensive against u64 overflow.
let outflow = lovelace
.checked_add(real_fee)
.ok_or_else(|| WalletError::Derivation("lovelace + fee overflow".into()))?;
let (final_fee, final_change) = match total_in_lovelace.checked_sub(outflow) {
Some(c) if c >= params.min_utxo_lovelace || change_must_exist => {
// change_must_exist + c < min_utxo: caller didn't bring
// enough ADA to support a token-bearing change output.
// Surface a clearer error than letting the chain reject
// the tx for a sub-min output.
if change_must_exist && c < params.min_utxo_lovelace {
return Err(WalletError::Derivation(format!(
"insufficient ADA for token-bearing change output: change={c} lovelace, min={}",
@ -510,7 +509,12 @@ fn prepare_payment(
(real_fee, c)
}
// ADA-only path with sub-min change — fold into fee.
Some(c) => (real_fee + c, 0),
Some(c) => (
real_fee
.checked_add(c)
.ok_or_else(|| WalletError::Derivation("fee + change overflow".into()))?,
0,
),
None => {
return Err(WalletError::Derivation(format!(
"insufficient funds for fee: total_in={total_in_lovelace} lovelace={lovelace} fee={real_fee}"
@ -536,28 +540,31 @@ fn prepare_payment(
// Re-shape the asset maps back into Vec<AssetSpec> for the
// summary — easier for callers to display than a BTreeMap.
// L-4 audit fix: replaced .expect() with proper error
// propagation. Logic-bug paths (we built the key) become
// typed errors instead of process-level panics.
let send_assets_vec: Vec<AssetSpec> = target_assets
.iter()
.map(|(k, v)| {
let (p, n) = split_asset_key(k).expect("we built this key");
AssetSpec {
let (p, n) = split_asset_key(k)?;
Ok::<AssetSpec, WalletError>(AssetSpec {
policy_id_hex: p.to_string(),
asset_name_hex: n.to_string(),
quantity: *v,
}
})
})
.collect();
.collect::<Result<_, _>>()?;
let change_assets_vec: Vec<AssetSpec> = change_assets
.iter()
.map(|(k, v)| {
let (p, n) = split_asset_key(k).expect("we built this key");
AssetSpec {
let (p, n) = split_asset_key(k)?;
Ok::<AssetSpec, WalletError>(AssetSpec {
policy_id_hex: p.to_string(),
asset_name_hex: n.to_string(),
quantity: *v,
}
})
})
.collect();
.collect::<Result<_, _>>()?;
let summary = PaymentSummary {
tx_hash: hex_encode(&built.tx_hash.0),

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. ADAMaps treasury 2-of-2): each party calls this in turn, then any party submits via wallet.submit_signed_tx."