//! 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::ZeroizeOnDrop; pub mod cip68; pub mod derive; 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}; // 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 { /// 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 { 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| { 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 { 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 { 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); 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 { 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) -> 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 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); } }