aldabra/crates/aldabra-core/src/lib.rs
Sulkta d1cc77969f 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.
2026-05-04 14:52:08 -07:00

363 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! aldabra core — keys, addresses, signing.
//!
//! This crate is the security boundary. Everything that touches private
//! key material lives here, and only here. No I/O, no network, no MCP.
//!
//! ## Layout
//!
//! - [`Mnemonic`] — 24-word BIP-39 input → entropy bytes.
//! - [`Mnemonic::into_root_key`] — Icarus CIP-3 master-key generation.
//! - [`RootKey`] — wraps [`ed25519_bip32::XPrv`].
//! - [`Network`] — bech32 prefix + protocol magic selector.
//!
//! Phases 1.3 (CIP-1852 child derivation), 1.4 (real base-address
//! construction), and signing land in follow-up modules; the placeholder
//! [`derive_base_address`] returns a sentinel address until then.
//!
//! ## Memory hygiene rule
//!
//! Anything holding private-key material zeroizes on drop:
//! - [`Mnemonic`]'s entropy via `ZeroizeOnDrop`.
//! - [`RootKey`]'s [`XPrv`] via its own [`Drop`] impl in `ed25519-bip32`.
//!
//! The decrypted phrase passed into [`Mnemonic::from_phrase`] is the
//! caller's responsibility to drop promptly — we copy the entropy out
//! and don't hold the source string.
use bip39::{Language, Mnemonic as Bip39Mnemonic};
use cryptoxide::hmac::Hmac;
use cryptoxide::pbkdf2::pbkdf2;
use cryptoxide::sha2::Sha512;
use ed25519_bip32::{XPrv, XPRV_SIZE};
use pallas_addresses::{
Network as PallasNetwork, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart,
};
use thiserror::Error;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
pub mod cip68;
pub mod derive;
pub mod inspect;
pub mod metadata;
pub mod mint;
pub mod plutus;
pub mod sign;
pub mod stake;
pub mod tx;
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::{
build_signed_cip68_nft_mint, build_signed_mint, build_signed_mint_with_metadata,
build_unsigned_mint, PolicySpec,
};
pub use sign::add_witness;
pub use plutus::{
build_signed_plutus_spend, looks_like_script_address, PlutusExUnits, PlutusInput,
PlutusVersion, DEFAULT_EX_UNITS, MIN_COLLATERAL_LOVELACE,
};
pub use stake::{build_signed_stake_delegation, parse_pool_id, STAKE_KEY_DEPOSIT_LOVELACE};
pub use tx::{
build_signed_payment, build_signed_payment_with_assets, build_unsigned_payment,
build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, PaymentSummary,
ProtocolParams, UnsignedPayment,
};
#[derive(Debug, Error)]
pub enum WalletError {
#[error("invalid mnemonic: {0}")]
InvalidMnemonic(String),
#[error("derivation failed: {0}")]
Derivation(String),
#[error("address encoding failed: {0}")]
Address(String),
#[error("not yet implemented (phase 1 scaffold)")]
NotYetImplemented,
}
/// A 24-word BIP-39 mnemonic, parsed and validated. Stores the raw
/// 32-byte entropy rather than the phrase — the source string is the
/// caller's responsibility to drop.
///
/// `ZeroizeOnDrop` ensures the entropy is wiped from RAM when this
/// struct is dropped.
#[derive(ZeroizeOnDrop)]
pub struct Mnemonic {
/// 256 bits of entropy (24 BIP-39 words × 11 bits = 264 bits, the
/// trailing 8 are the checksum). The bip39 crate's `to_entropy()`
/// returns exactly the 32 entropy bytes.
entropy: [u8; 32],
}
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.
pub fn from_phrase(phrase: &str) -> Result<Self, WalletError> {
let parsed = Bip39Mnemonic::parse_in(Language::English, phrase)
.map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?;
if parsed.word_count() != 24 {
return Err(WalletError::InvalidMnemonic(format!(
"expected 24 words, got {}",
parsed.word_count()
)));
}
let entropy_vec = parsed.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 })
}
/// Derive the Cardano CIP-3 root extended private key (Icarus
/// variant, no passphrase). Consumes the mnemonic so the entropy
/// is dropped + zeroized immediately after.
pub fn into_root_key(self) -> Result<RootKey, WalletError> {
self.into_root_key_with_passphrase("")
}
/// Derive the Cardano CIP-3 root extended private key with a
/// caller-supplied BIP-39 passphrase. Empty string = no passphrase
/// = the default Icarus / Yoroi behaviour.
///
/// Algorithm (per Cardano Icarus master-key generation):
/// 1. `xprv = PBKDF2-HMAC-SHA512(password=passphrase, salt=entropy,
/// c=4096, dkLen=96)`
/// 2. Bit-clamp the first 32 bytes so the result is a valid extended
/// Ed25519 scalar with the 3rd-highest bit cleared
/// (`normalize_bytes_force3rd`).
pub fn into_root_key_with_passphrase(
self,
passphrase: &str,
) -> Result<RootKey, WalletError> {
let mut xprv_bytes = [0u8; XPRV_SIZE];
let mut hmac = Hmac::new(Sha512::new(), passphrase.as_bytes());
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 })
}
}
/// CIP-3 root extended private key. Wraps an [`XPrv`]
/// (96 bytes: extended secret + chain code). [`XPrv`]'s own [`Drop`]
/// impl wipes the bytes from memory when this struct drops.
pub struct RootKey {
pub(crate) xprv: XPrv,
}
impl RootKey {
/// Borrow the underlying [`XPrv`] for derivation. Crate-internal
/// code uses this; external callers should go through the
/// `derive_*` helpers which return purpose-specific key types.
pub(crate) fn xprv(&self) -> &XPrv {
&self.xprv
}
}
/// Network parameter — bech32 prefix + protocol magic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Network {
Mainnet,
Preview,
Preprod,
}
impl Network {
pub fn bech32_hrp_prefix(&self) -> &'static str {
match self {
Network::Mainnet => "addr",
Network::Preview | Network::Preprod => "addr_test",
}
}
/// Map our three-variant Network onto pallas-addresses' two
/// real variants. Cardano's network header byte only distinguishes
/// `Mainnet` from `Testnet` — the protocol magic differentiates
/// Preview vs Preprod at the chain layer, not at the address layer.
/// Both testnet flavours therefore share the `addr_test1…` HRP.
pub fn to_pallas(&self) -> PallasNetwork {
match self {
Network::Mainnet => PallasNetwork::Mainnet,
Network::Preview | Network::Preprod => PallasNetwork::Testnet,
}
}
}
/// Derive a Shelley base address (payment + stake) at the given
/// account / payment-index path:
///
/// - payment path: `m/1852'/1815'/account'/0/index`
/// - stake path: `m/1852'/1815'/account'/2/0`
///
/// Both keys hash through Blake2b-224 to produce 28-byte key hashes,
/// which combine via [`ShelleyPaymentPart::key_hash`] +
/// [`ShelleyDelegationPart::key_hash`] into a Shelley base address,
/// emitted as bech32 with the right HRP for the chosen network.
pub fn derive_base_address(
root: &RootKey,
network: Network,
account: u32,
index: u32,
) -> Result<String, WalletError> {
let payment = derive_payment_key(root, account, index);
let stake = derive_stake_key(root, account);
let address = ShelleyAddress::new(
network.to_pallas(),
ShelleyPaymentPart::key_hash(payment.public_key_hash()),
ShelleyDelegationPart::key_hash(stake.public_key_hash()),
);
address
.to_bech32()
.map_err(|e| WalletError::Address(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
/// Canonical 24-word BIP-39 test mnemonic. Used widely in the
/// Cardano ecosystem (cardano-address, cardano-cli docs) so derived
/// vectors are easy to cross-check.
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",
);
/// `Mnemonic` deliberately doesn't `derive(Debug)` — printing the
/// entropy in a panic message would leak key material. Tests use
/// this helper instead of `.unwrap_err()` (which requires `Debug`
/// on the `Ok` variant).
fn expect_invalid(result: Result<Mnemonic, WalletError>) -> WalletError {
match result {
Ok(_) => panic!("expected WalletError::InvalidMnemonic, got Ok(_)"),
Err(e) => e,
}
}
#[test]
fn rejects_short_phrase() {
let err = expect_invalid(Mnemonic::from_phrase("one two three"));
assert!(matches!(err, WalletError::InvalidMnemonic(_)));
}
#[test]
fn rejects_bad_checksum() {
// 24 abandons in a row has a bad checksum — the canonical valid
// form ends in "art".
let bad = "abandon ".repeat(24);
let err = expect_invalid(Mnemonic::from_phrase(bad.trim()));
assert!(matches!(err, WalletError::InvalidMnemonic(_)));
}
#[test]
fn parses_canonical_24_word_mnemonic() {
let m = Mnemonic::from_phrase(ABANDON_ART).expect("valid mnemonic");
// 24 abandon-mostly words → entropy is all zeros.
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();
let root = m.into_root_key().expect("CIP-3 derivation works");
// The derived XPrv must be 96 bytes total and the bit-clamp
// must have cleared the 3rd highest bit at byte 31.
assert_eq!(root.xprv().as_ref().len(), XPRV_SIZE);
assert!(root.xprv().is_3rd_highest_bit_clear());
}
#[test]
fn mainnet_base_address_round_trips() {
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
let root = m.into_root_key().unwrap();
let addr = derive_base_address(&root, Network::Mainnet, 0, 0).unwrap();
assert!(addr.starts_with("addr1"), "got: {addr}");
// Round-trip — pallas should parse what we just emitted and
// give back a Shelley mainnet address.
let parsed = pallas_addresses::Address::from_bech32(&addr)
.expect("our own bech32 output parses");
match parsed {
pallas_addresses::Address::Shelley(s) => {
assert_eq!(s.network(), pallas_addresses::Network::Mainnet);
}
other => panic!("expected Shelley address, got {other:?}"),
}
}
#[test]
fn preprod_base_address_uses_testnet_hrp() {
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
let root = m.into_root_key().unwrap();
let addr = derive_base_address(&root, Network::Preprod, 0, 0).unwrap();
assert!(addr.starts_with("addr_test1"), "got: {addr}");
}
#[test]
fn different_indices_produce_different_addresses() {
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
let root = m.into_root_key().unwrap();
let a0 = derive_base_address(&root, Network::Mainnet, 0, 0).unwrap();
let a1 = derive_base_address(&root, Network::Mainnet, 0, 1).unwrap();
assert_ne!(a0, a1);
}
}