diff --git a/Cargo.lock b/Cargo.lock index 6e4445d..c9187cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index cb4d3b4..44feacc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/aldabra-chain/src/koios.rs b/crates/aldabra-chain/src/koios.rs index 0bc00c2..b400844 100644 --- a/crates/aldabra-chain/src/koios.rs +++ b/crates/aldabra-chain/src/koios.rs @@ -130,6 +130,13 @@ fn parse_u64(s: &str, field: &str) -> Result { .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 { let body = TxHashesBody { tx_hashes: vec![tx_hash] }; let raw: Vec = 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\"")); } diff --git a/crates/aldabra-chain/src/lib.rs b/crates/aldabra-chain/src/lib.rs index f5dfe7f..82657d4 100644 --- a/crates/aldabra-chain/src/lib.rs +++ b/crates/aldabra-chain/src/lib.rs @@ -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, + block_height: u64, epoch: Option, }, - /// 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, } diff --git a/crates/aldabra-core/src/inspect.rs b/crates/aldabra-core/src/inspect.rs new file mode 100644 index 0000000..d01bfa4 --- /dev/null +++ b/crates/aldabra-core/src/inspect.rs @@ -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, + /// 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, + /// `valid_from_slot` if set (tx invalid before this slot). + pub valid_from_slot: Option, + /// `invalid_from_slot` if set (tx invalid at-or-after this slot). + pub invalid_from_slot: Option, + /// Certificates in the tx body. Common: stake registration, + /// stake delegation, DRep ops. + pub certificates: Vec, + /// Mint actions. Positive amounts mint, negative amounts burn. + pub mint: Vec, + /// 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, + /// 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) { + 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) { + 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 { + 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 = body.outputs.iter().map(output_summary).collect(); + + let certificates: Vec = body + .certificates + .as_ref() + .map(|c| c.iter().map(cert_summary).collect()) + .unwrap_or_default(); + + let mut mint_entries: Vec = 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 { + 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()); + } +} diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index 93699e4..9ea432b 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -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), 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| { + 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(); diff --git a/crates/aldabra-core/src/plutus.rs b/crates/aldabra-core/src/plutus.rs index 9ec41ee..f691f9f 100644 --- a/crates/aldabra-core/src/plutus.rs +++ b/crates/aldabra-core/src/plutus.rs @@ -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, WalletError> { Ok(Hash::<32>::new(out)) } -fn decode_hex(s: &str) -> Result, 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. diff --git a/crates/aldabra-core/src/sign.rs b/crates/aldabra-core/src/sign.rs index bcac7a4..42f2f65 100644 --- a/crates/aldabra-core/src/sign.rs +++ b/crates/aldabra-core/src/sign.rs @@ -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(); diff --git a/crates/aldabra-core/src/tx.rs b/crates/aldabra-core/src/tx.rs index 8e0cbfb..6d6a237 100644 --- a/crates/aldabra-core/src/tx.rs +++ b/crates/aldabra-core/src/tx.rs @@ -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 { 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, 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 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 = 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 { policy_id_hex: p.to_string(), asset_name_hex: n.to_string(), quantity: *v, - } + }) }) - .collect(); + .collect::>()?; let change_assets_vec: Vec = 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 { policy_id_hex: p.to_string(), asset_name_hex: n.to_string(), quantity: *v, - } + }) }) - .collect(); + .collect::>()?; let summary = PaymentSummary { tx_hash: hex_encode(&built.tx_hash.0), diff --git a/crates/aldabra-mcp/src/bootstrap.rs b/crates/aldabra-mcp/src/bootstrap.rs index 8222be1..e12aa58 100644 --- a/crates/aldabra-mcp/src/bootstrap.rs +++ b/crates/aldabra-mcp/src/bootstrap.rs @@ -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` 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> { + std::env::var("ALDABRA_PASSPHRASE").ok().map(Zeroizing::new) +} + +fn prompt_or_env_passphrase(confirm: bool) -> Result> { + 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> { + 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 { 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 { // 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 { } } -#[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 { + 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)] diff --git a/crates/aldabra-mcp/src/main.rs b/crates/aldabra-mcp/src/main.rs index 2ebdcfc..5999ada 100644 --- a/crates/aldabra-mcp/src/main.rs +++ b/crates/aldabra-mcp/src/main.rs @@ -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 = 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(()); } diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index b831f33..26955a8 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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, + /// 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, + /// 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, + /// 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 { 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 { 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 { + // 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 { + 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."