phase 1: full read path — bip39 + cip-3 + cip-1852 + koios + age-mnemonic + rmcp

end-to-end working wallet: paste 24-word mnemonic, age-encrypt at rest,
on unlock derive root + payment + stake keys, build cip-19 base address,
serve four tools over mcp stdio (wallet.address, wallet.network,
wallet.balance, wallet.utxos).

deps added: ed25519-bip32 0.4 (pallas only ships raw ed25519, not the
cardano variant of bip32 hd derivation), cryptoxide 0.4 for pbkdf2-hmac-sha512,
age 0.10 for at-rest mnemonic encryption, rpassword 7 for tty-only passphrase
prompts, toml 0.9 for config.toml.

new modules:
- crates/aldabra-core/src/derive.rs — payment + stake key derivation, hash
- crates/aldabra-chain/src/koios.rs — real reqwest impl, asset aggregation
- crates/aldabra-mcp/src/{bootstrap,config,tools}.rs

caught one bug pre-flight: get_balance was clobbering same-asset
quantities across utxos instead of summing. fixed + regression test.

headless support via ALDABRA_PASSPHRASE env (mcp clients own stdin so
the rpassword prompt path can't run). docker secret / systemd
EnvironmentFile sources it in production.

dockerfile: multi-stage rust:1.95-bookworm → debian:bookworm-slim, tini
as pid1, non-root aldabra user, /var/lib/aldabra owned 700.

29 unit tests + 1 ignored live-koios test. preprod smoke test exercised
initialize → tools/list → tools/call wallet.address end-to-end via
piped json-rpc; correct preprod address came back from canonical
abandon-art mnemonic.

phase 2 (send) is next.
This commit is contained in:
Sulkta 2026-05-04 11:09:00 -07:00
parent edc976e5d9
commit 2b4fdff0c0
14 changed files with 4389 additions and 167 deletions

View file

@ -0,0 +1,168 @@
//! CIP-1852 child key derivation.
//!
//! Cardano hardened-account / soft-chain HD paths land here. The
//! external surface is two key types ([`PaymentKey`], [`StakeKey`])
//! and two derivation entry points ([`derive_payment_key`],
//! [`derive_stake_key`]).
//!
//! ## CIP-1852 path layout
//!
//! ```text
//! m / 1852' / 1815' / account' / chain / index
//! ```
//!
//! - `1852'` — purpose (Cardano Shelley)
//! - `1815'` — Cardano coin type (Ada Lovelace's birth year)
//! - `account'` — hardened account index, usually 0
//! - `chain` — `0` external (payment), `1` internal (change),
//! `2` stake key
//! - `index` — soft index within the chain
//!
//! ## Hardened indexing
//!
//! BIP-32 hardened indices are `index | 0x8000_0000`. The first
//! three components of the path above are hardened; the last two
//! (`chain`, `index`) are soft.
//!
//! ## Why a separate module
//!
//! Keeping derivation in its own file means [`lib.rs`] stays focused
//! on the security-boundary types (Mnemonic, RootKey). New chain
//! types (Byron, future Conway-era keys) plug in here without
//! mutating the root crate.
use ed25519_bip32::{DerivationScheme, XPrv};
use pallas_crypto::hash::{Hash, Hasher};
use crate::RootKey;
const SCHEME: DerivationScheme = DerivationScheme::V2;
/// Hardened-bit OR mask. BIP-32 hardened indices are `n | HARDENED`.
const HARDENED: u32 = 0x8000_0000;
/// CIP-1852 purpose constant: Shelley.
const PURPOSE: u32 = HARDENED | 1852;
/// Cardano coin type per SLIP-44 / CIP-1852.
const COIN_TYPE: u32 = HARDENED | 1815;
/// Chain index for external (payment) addresses per CIP-1852.
const CHAIN_PAYMENT: u32 = 0;
/// Chain index for stake keys per CIP-1852.
const CHAIN_STAKE: u32 = 2;
/// A payment key derived at `m/1852'/1815'/account'/0/index`. Wraps
/// an [`XPrv`] whose own [`Drop`] impl wipes the bytes.
pub struct PaymentKey {
xprv: XPrv,
}
impl PaymentKey {
/// Blake2b-224 hash of the 32-byte raw public key — the canonical
/// payment-key hash that goes into a Shelley base address's
/// payment part.
pub fn public_key_hash(&self) -> Hash<28> {
Hasher::<224>::hash(self.xprv.public().public_key_bytes())
}
}
/// A stake key derived at `m/1852'/1815'/account'/2/0`. Same memory
/// hygiene as [`PaymentKey`].
pub struct StakeKey {
xprv: XPrv,
}
impl StakeKey {
/// Blake2b-224 hash of the raw stake public key — goes into the
/// delegation part of a Shelley base address.
pub fn public_key_hash(&self) -> Hash<28> {
Hasher::<224>::hash(self.xprv.public().public_key_bytes())
}
}
/// Derive a payment key at `m/1852'/1815'/account'/0/index`.
pub fn derive_payment_key(root: &RootKey, account: u32, index: u32) -> PaymentKey {
let xprv = root
.xprv()
.derive(SCHEME, PURPOSE)
.derive(SCHEME, COIN_TYPE)
.derive(SCHEME, HARDENED | account)
.derive(SCHEME, CHAIN_PAYMENT)
.derive(SCHEME, index);
PaymentKey { xprv }
}
/// Derive the account stake key at `m/1852'/1815'/account'/2/0`.
/// Each account has exactly one stake key (chain index 2, soft 0).
pub fn derive_stake_key(root: &RootKey, account: u32) -> StakeKey {
let xprv = root
.xprv()
.derive(SCHEME, PURPOSE)
.derive(SCHEME, COIN_TYPE)
.derive(SCHEME, HARDENED | account)
.derive(SCHEME, CHAIN_STAKE)
.derive(SCHEME, 0);
StakeKey { xprv }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Mnemonic;
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 root_from_canonical() -> RootKey {
Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap()
}
#[test]
fn payment_and_stake_key_hashes_are_28_bytes() {
let root = root_from_canonical();
let payment = derive_payment_key(&root, 0, 0);
let stake = derive_stake_key(&root, 0);
// Hash<28> is a strong type, but we still want to confirm the
// raw byte length matches what pallas-addresses expects.
assert_eq!(payment.public_key_hash().as_ref().len(), 28);
assert_eq!(stake.public_key_hash().as_ref().len(), 28);
}
#[test]
fn derivation_is_deterministic() {
let root_a = root_from_canonical();
let root_b = root_from_canonical();
let pa = derive_payment_key(&root_a, 0, 0);
let pb = derive_payment_key(&root_b, 0, 0);
assert_eq!(pa.public_key_hash(), pb.public_key_hash());
}
#[test]
fn account_and_index_change_the_payment_hash() {
let root = root_from_canonical();
let p_0_0 = derive_payment_key(&root, 0, 0);
let p_0_1 = derive_payment_key(&root, 0, 1);
let p_1_0 = derive_payment_key(&root, 1, 0);
assert_ne!(p_0_0.public_key_hash(), p_0_1.public_key_hash());
assert_ne!(p_0_0.public_key_hash(), p_1_0.public_key_hash());
}
#[test]
fn payment_and_stake_keys_differ_at_same_account() {
let root = root_from_canonical();
let payment = derive_payment_key(&root, 0, 0);
let stake = derive_stake_key(&root, 0);
// Same account but chain index 0 vs 2 — must produce
// different key hashes.
assert_ne!(payment.public_key_hash(), stake.public_key_hash());
}
}

View file

@ -3,28 +3,41 @@
//! 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 (target)
//! ## Layout
//!
//! - [`mnemonic`] — 24-word BIP-39 input → root key (CIP-3)
//! - [`derive`] — Root key → payment + stake key (CIP-1852 paths)
//! - [`address`] — Public keys → bech32 addresses (mainnet / testnet)
//! - [`signing`] — Sign an unsigned transaction body
//! - [`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.
//!
//! ## Phase 1 (this scaffold)
//!
//! Just types + a placeholder address-derivation function. Real impl
//! lands as we wire up `pallas-crypto`'s key derivation API.
//! 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 that holds a private key MUST `derive(ZeroizeOnDrop)` or
//! manually zeroize when going out of scope. Use the `zeroize` crate.
//! This is non-negotiable — RAM-resident keys leak via core dumps,
//! swap, hibernate state, etc.
//! 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 derive;
pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey};
#[derive(Debug, Error)]
pub enum WalletError {
#[error("invalid mnemonic: {0}")]
@ -40,53 +53,87 @@ pub enum WalletError {
NotYetImplemented,
}
/// A 24-word BIP-39 mnemonic. Held in memory only while deriving keys;
/// callers should drop this immediately after `derive_root_key`.
/// 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 {
/// Stored as a single string (joined with spaces). The
/// `ZeroizeOnDrop` derive ensures this gets wiped from RAM when
/// the struct is dropped.
phrase: String,
/// 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 mnemonic from a whitespace-separated string.
/// Validates word count + checksum via the `bip39` crate.
///
/// # Phase 1
/// TODO: wire up `bip39::Mnemonic::parse_in` once we lock the
/// API. For now this just stores the phrase verbatim — DO NOT
/// rely on validation yet.
/// 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> {
// TODO(phase 1): real validation
if phrase.split_whitespace().count() != 24 {
return Err(WalletError::InvalidMnemonic(
"expected 24 words".into(),
));
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()
)));
}
Ok(Self {
phrase: phrase.to_string(),
})
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 key from this mnemonic. Consumes
/// the mnemonic so the source phrase is dropped + zeroized
/// immediately after.
/// 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> {
// TODO(phase 1): pallas-crypto's PBKDF2 + entropy + chain code
// derivation per CIP-3. Reference:
// https://input-output-hk.github.io/cardano-wallet/concepts/master-key-generation
Err(WalletError::NotYetImplemented)
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);
Ok(RootKey { xprv })
}
}
/// CIP-3 root key. Holds the seed material from which payment + stake
/// keys are derived via CIP-1852 paths. Zeroized on drop.
#[derive(ZeroizeOnDrop)]
/// 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 {
/// 96 bytes per CIP-3 (extended secret + chain code).
bytes: [u8; 96],
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.
@ -104,39 +151,139 @@ impl Network {
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 base address (payment + stake) at account 0, address index 0.
/// Derive a Shelley base address (payment + stake) at the given
/// account / payment-index path:
///
/// # Phase 1
/// TODO: real CIP-1852 derivation using pallas-crypto's HD key derivation.
/// For now this returns a placeholder that lets the MCP layer be tested
/// without real keys.
/// - 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,
root: &RootKey,
network: Network,
_account: u32,
_index: u32,
account: u32,
index: u32,
) -> Result<String, WalletError> {
// TODO(phase 1): real implementation
let prefix = network.bech32_hrp_prefix();
Ok(format!("{prefix}1placeholder_phase_1_scaffold"))
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::*;
#[test]
fn mnemonic_word_count_validation() {
let too_few = "one two three";
assert!(Mnemonic::from_phrase(too_few).is_err());
/// 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 placeholder_address_has_network_prefix() {
let dummy_root = RootKey { bytes: [0u8; 96] };
let addr = derive_base_address(&dummy_root, Network::Mainnet, 0, 0).unwrap();
assert!(addr.starts_with("addr1"));
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);
}
}