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.
363 lines
14 KiB
Rust
363 lines
14 KiB
Rust
//! 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);
|
||
}
|
||
}
|