Phase 6, key-credentialed slice (script-DRep bridge for the DAO is the remaining sub-arc). ## pallas-fork patch (Sulkta-Coop/pallas feat-aux-data HEAD 507fd9da) Threads voting_procedures through StagingTransaction → conway:: build_conway_raw, mirroring the auxiliary_data + certificates patches. - pallas-txbuilder/src/transaction/model.rs: voting_procedures field + builder methods .voting_procedures() / .clear_voting_procedures() - pallas-txbuilder/src/conway.rs: VotingProcedures::decode_fragment on the way out, assigned to TransactionBody.voting_procedures - BRANCH-NOTES.md: section 3 added documenting the new patch - 2 new tests (round-trip + negative path) on the txbuilder side aldabra Cargo.lock SHAs bumped to the new HEAD. ## aldabra-core/src/governance.rs - VoteChoice enum (Yes/No/Abstain) with into_pallas() conversion - build_signed_drep_vote_cast — assembles VotingProcedures CBOR (NonEmptyKeyValuePairs<Voter, NonEmptyKeyValuePairs<GovActionId, VotingProcedure>>) with this wallet's stake credential as a Voter::DRepKey, attaches via the new pallas API, dual-witness signs. - Optional CIP-100 anchor on the vote. ## aldabra-mcp/src/tools.rs - wallet_drep_vote_cast tool: gov_action_tx_hash + gov_action_index + vote (yes/no/abstain) + optional anchor. What's still scope-of-Phase-6: - Script-credentialed DRep voting (the DAO governor as DRep, with redeemer-driven authorization). Needs a different signing path since the voter is a script credential, not a key credential. Separate builder; defer until Sulkta wants to actually bridge.
442 lines
17 KiB
Rust
442 lines
17 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 governance;
|
||
pub mod inspect;
|
||
pub mod metadata;
|
||
pub mod mint;
|
||
pub mod plutus;
|
||
pub mod plutus_cost_models;
|
||
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 governance::{
|
||
build_signed_drep_deregistration, build_signed_drep_registration,
|
||
build_signed_drep_vote_cast, build_signed_vote_delegation, parse_drep_target,
|
||
DRepTarget, VoteChoice, DREP_REGISTRATION_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
|
||
}
|
||
|
||
/// Construct from raw 96-byte XPrv (64-byte extended secret +
|
||
/// 32-byte chain code). Power-user import path — bypasses the
|
||
/// BIP-39 mnemonic flow entirely. Used when ingesting a key
|
||
/// generated by `cardano-cli address key-gen` or extracted from
|
||
/// a different Cardano wallet (the cnode `root.prv` file is the
|
||
/// canonical example).
|
||
///
|
||
/// Validates the byte count; the key itself is treated as
|
||
/// trusted input — this isn't a place to enforce semantic
|
||
/// correctness because any 96-byte sequence is a valid XPrv
|
||
/// from the type's perspective.
|
||
pub fn from_xprv_bytes(bytes: &[u8]) -> Result<Self, WalletError> {
|
||
if bytes.len() != XPRV_SIZE {
|
||
return Err(WalletError::Derivation(format!(
|
||
"xprv must be {XPRV_SIZE} bytes, got {}",
|
||
bytes.len()
|
||
)));
|
||
}
|
||
let mut buf = [0u8; XPRV_SIZE];
|
||
buf.copy_from_slice(bytes);
|
||
let xprv = XPrv::from_bytes_verified(buf)
|
||
.map_err(|e| WalletError::Derivation(format!("xprv verify: {e:?}")))?;
|
||
Ok(RootKey { xprv })
|
||
}
|
||
|
||
/// Construct from a bech32-encoded extended secret key —
|
||
/// specifically the `root_xsk1...` shape that Cardano CLI's
|
||
/// HD wallet tooling (cardano-address, cardano-hw-cli, the
|
||
/// IOG node `priv/wallet/<name>/root.prv` file) emits.
|
||
///
|
||
/// HRP must be exactly `root_xsk` — we refuse other extended-
|
||
/// key flavours (`acct_xsk`, `addr_xsk`, etc) so callers don't
|
||
/// accidentally import a derived child as if it were the root.
|
||
/// If you actually want to import an account-level or address-
|
||
/// level key, you'd be locking yourself out of CIP-1852
|
||
/// derivation; we'd rather force the explicit conversation.
|
||
pub fn from_root_xsk_bech32(s: &str) -> Result<Self, WalletError> {
|
||
let trimmed = s.trim();
|
||
let (hrp, data, variant) = bech32::decode(trimmed)
|
||
.map_err(|e| WalletError::Derivation(format!("bech32 decode: {e}")))?;
|
||
if hrp != "root_xsk" {
|
||
return Err(WalletError::Derivation(format!(
|
||
"expected root_xsk bech32, got {hrp:?}"
|
||
)));
|
||
}
|
||
if variant != bech32::Variant::Bech32 {
|
||
return Err(WalletError::Derivation(
|
||
"expected Bech32 (not Bech32m) for root_xsk".into(),
|
||
));
|
||
}
|
||
use bech32::FromBase32;
|
||
let bytes = Vec::<u8>::from_base32(&data)
|
||
.map_err(|e| WalletError::Derivation(format!("bech32 base32: {e}")))?;
|
||
Self::from_xprv_bytes(&bytes)
|
||
}
|
||
|
||
/// Encode the underlying XPrv bytes as `root_xsk1...` bech32.
|
||
/// Symmetric counterpart to [`from_root_xsk_bech32`]. Useful for
|
||
/// emitting the same shape that cardano-cli / cardano-address /
|
||
/// the IOG node's `priv/wallet/<name>/root.prv` files store.
|
||
/// **Sensitive output** — anyone with this string can spend the
|
||
/// wallet's funds.
|
||
pub fn to_root_xsk_bech32(&self) -> Result<String, WalletError> {
|
||
use bech32::ToBase32;
|
||
bech32::encode(
|
||
"root_xsk",
|
||
self.xprv.as_ref().to_base32(),
|
||
bech32::Variant::Bech32,
|
||
)
|
||
.map_err(|e| WalletError::Derivation(format!("bech32 encode: {e}")))
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
}
|