aldabra: --bootstrap-from-xprv power-user import path

Adds RootKey::from_root_xsk_bech32() / from_xprv_bytes() /
to_root_xsk_bech32() so RootKey can ingest + emit the same
bech32 root extended secret key shape that cardano-cli +
cardano-address + the IOG node priv/wallet/<name>/root.prv
file already use. HRP is strictly root_xsk — refuses
acct_xsk/addr_xsk to keep the import scoped to the actual
HD root.

New CLI flag --bootstrap-from-xprv runs an interactive
import: paste root_xsk1... bech32, prompt passphrase,
encrypt, persist as root-xprv.age (parallel to mnemonic.age).
Refuses to overwrite either existing key file (per Cobb's
no-delete-crypto-keys rule — caller has to move aside, not
delete).

Startup path now checks for either mnemonic.age OR
root-xprv.age; refuses if both exist (ambiguous). Same
RootKey downstream — derivation tree, signing, all of it
works identically whether the key came in via mnemonic
or xprv import.

Test root_xprv_round_trip proves the imported xprv derives
to the same address as the mnemonic-imported equivalent.
This commit is contained in:
Kayos 2026-05-05 06:38:01 -07:00
parent 057f623312
commit e712f370f0
3 changed files with 275 additions and 45 deletions

View file

@ -190,6 +190,78 @@ impl RootKey {
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.

View file

@ -85,6 +85,14 @@ fn unlock_passphrase() -> Result<Zeroizing<String>> {
const MNEMONIC_FILENAME: &str = "mnemonic.age";
/// Encrypted root extended private key (BIP-32-Ed25519) — the import
/// shape for keys that came from outside the BIP-39 mnemonic flow
/// (cardano-cli `priv/wallet/<name>/root.prv`, cardano-address,
/// cardano-hw-cli, etc). Stored at the same data dir as
/// `mnemonic.age`; only one of the two should ever exist for a
/// given wallet.
const ROOT_XPRV_FILENAME: &str = "root-xprv.age";
/// Encrypt a mnemonic phrase with a passphrase. Pure — no I/O, no
/// prompts. Used by the interactive bootstrap and exposed for tests.
pub fn encrypt_mnemonic(phrase: &str, passphrase: &str) -> Result<Vec<u8>> {
@ -129,48 +137,134 @@ pub fn mnemonic_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(MNEMONIC_FILENAME)
}
/// Interactive bootstrap. If `mnemonic.age` exists at `data_dir`,
/// prompt for the passphrase and unlock it. Otherwise, run the
/// first-run flow: prompt for phrase + passphrase, encrypt, write,
/// then derive.
/// Path of the encrypted root xprv for a given data dir.
pub fn root_xprv_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(ROOT_XPRV_FILENAME)
}
/// Encrypt a `root_xsk1...` bech32 string with a passphrase. We
/// encrypt the bech32 form (not the raw 96 bytes) so the on-disk
/// payload round-trips losslessly back into the same string the
/// user pasted, which makes manual recovery + cross-tool inspection
/// less error-prone.
pub fn encrypt_root_xprv_bech32(bech32_str: &str, passphrase: &str) -> Result<Vec<u8>> {
encrypt_mnemonic(bech32_str, passphrase)
}
/// Decrypt an age-encrypted root xprv blob. Returns the bech32
/// `root_xsk1...` string in `Zeroizing<String>` so it gets wiped
/// when dropped.
pub fn decrypt_root_xprv_bech32(blob: &[u8], passphrase: &str) -> Result<Zeroizing<String>> {
decrypt_mnemonic(blob, passphrase)
}
/// Interactive bootstrap. Checks for an existing key at `data_dir` in
/// either the BIP-39 mnemonic shape (`mnemonic.age`) or the imported-
/// xprv shape (`root-xprv.age`); whichever exists is unlocked. If
/// neither exists, falls back to the BIP-39-paste first-run flow.
///
/// Refuses to start if BOTH files are present — that's an ambiguous
/// state and the user needs to pick one.
///
/// Stderr-only output. stdout is reserved for the MCP transport.
pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
let path = mnemonic_path(data_dir);
let mn_path = mnemonic_path(data_dir);
let xprv_path = root_xprv_path(data_dir);
if path.exists() {
eprintln!("aldabra: unlocking mnemonic at {}", path.display());
let blob = fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
if mn_path.exists() && xprv_path.exists() {
return Err(anyhow!(
"both {} and {} exist. exactly one wallet key per data dir — \
move one aside (rename, don't delete) and re-run.",
mn_path.display(),
xprv_path.display()
));
}
if mn_path.exists() {
eprintln!("aldabra: unlocking mnemonic at {}", mn_path.display());
let blob = fs::read(&mn_path).with_context(|| format!("reading {}", mn_path.display()))?;
let passphrase = unlock_passphrase()?;
let phrase = decrypt_mnemonic(&blob, &passphrase)?;
let mnemonic = Mnemonic::from_phrase(&phrase)?;
Ok(mnemonic.into_root_key()?)
} else {
eprintln!("aldabra: no mnemonic found at {}", path.display());
eprintln!("first-run bootstrap — this writes an encrypted mnemonic to disk.\n");
fs::create_dir_all(data_dir)
.with_context(|| format!("creating {}", data_dir.display()))?;
eprint!("paste 24-word BIP-39 mnemonic (visible) and press Enter: ");
std::io::stderr().flush().ok();
let mut phrase_buf = Zeroizing::new(String::new());
std::io::stdin()
.lock()
.read_line(&mut phrase_buf)
.context("reading mnemonic from stdin")?;
let trimmed: &str = phrase_buf.trim();
// Validate before asking for passphrase — fail fast on bad input.
Mnemonic::from_phrase(trimmed)?;
let passphrase = prompt_or_env_passphrase(true)?;
let blob = encrypt_mnemonic(trimmed, &passphrase)?;
write_owner_only(&path, &blob)?;
eprintln!("aldabra: mnemonic encrypted to {}", path.display());
let mnemonic = Mnemonic::from_phrase(trimmed)?;
Ok(mnemonic.into_root_key()?)
return Ok(mnemonic.into_root_key()?);
}
if xprv_path.exists() {
eprintln!("aldabra: unlocking root xprv at {}", xprv_path.display());
let blob =
fs::read(&xprv_path).with_context(|| format!("reading {}", xprv_path.display()))?;
let passphrase = unlock_passphrase()?;
let bech32_str = decrypt_root_xprv_bech32(&blob, &passphrase)?;
return Ok(RootKey::from_root_xsk_bech32(&bech32_str)?);
}
eprintln!("aldabra: no key found at {}", data_dir.display());
eprintln!("first-run bootstrap — this writes an encrypted mnemonic to disk.\n");
fs::create_dir_all(data_dir)
.with_context(|| format!("creating {}", data_dir.display()))?;
eprint!("paste 24-word BIP-39 mnemonic (visible) and press Enter: ");
std::io::stderr().flush().ok();
let mut phrase_buf = Zeroizing::new(String::new());
std::io::stdin()
.lock()
.read_line(&mut phrase_buf)
.context("reading mnemonic from stdin")?;
let trimmed: &str = phrase_buf.trim();
// Validate before asking for passphrase — fail fast on bad input.
Mnemonic::from_phrase(trimmed)?;
let passphrase = prompt_or_env_passphrase(true)?;
let blob = encrypt_mnemonic(trimmed, &passphrase)?;
write_owner_only(&mn_path, &blob)?;
eprintln!("aldabra: mnemonic encrypted to {}", mn_path.display());
let mnemonic = Mnemonic::from_phrase(trimmed)?;
Ok(mnemonic.into_root_key()?)
}
/// Import a root xprv (`root_xsk1...` bech32) from stdin, encrypt
/// with a passphrase, and persist as `root-xprv.age`. Returns the
/// derived [`RootKey`]. Refuses to overwrite an existing key file
/// (mnemonic OR xprv) — caller has to move the existing one aside
/// (per Cobb's no-delete-crypto-keys rule).
pub fn import_root_xprv(data_dir: &Path) -> Result<RootKey> {
let mn_path = mnemonic_path(data_dir);
let xprv_path = root_xprv_path(data_dir);
if mn_path.exists() {
return Err(anyhow!(
"{} already exists — move it aside (rename, don't delete) before importing.",
mn_path.display()
));
}
if xprv_path.exists() {
return Err(anyhow!(
"{} already exists — move it aside (rename, don't delete) before importing.",
xprv_path.display()
));
}
fs::create_dir_all(data_dir)
.with_context(|| format!("creating {}", data_dir.display()))?;
eprint!("paste root_xsk1... bech32 root extended secret key and press Enter: ");
std::io::stderr().flush().ok();
let mut buf = Zeroizing::new(String::new());
std::io::stdin()
.lock()
.read_line(&mut buf)
.context("reading root_xsk from stdin")?;
let trimmed: &str = buf.trim();
// Validate before asking for passphrase.
let root = RootKey::from_root_xsk_bech32(trimmed)?;
let passphrase = prompt_or_env_passphrase(true)?;
let blob = encrypt_root_xprv_bech32(trimmed, &passphrase)?;
write_owner_only(&xprv_path, &blob)?;
eprintln!("aldabra: root xprv encrypted to {}", xprv_path.display());
Ok(root)
}
/// Print a freshly generated 24-word mnemonic to stderr and exit.
@ -278,4 +372,57 @@ mod tests {
let b = encrypt_mnemonic(ABANDON_ART, "hunter2").unwrap();
assert_ne!(a, b);
}
/// Public-API derivation of the canonical abandon-art root_xsk
/// for test reuse. Goes through Mnemonic→RootKey→bech32, which
/// is the same path power users will hit from outside the crate.
fn abandon_art_root_xsk_bech32() -> String {
Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap()
.to_root_xsk_bech32()
.unwrap()
}
#[test]
fn root_xprv_round_trip() {
let bech = abandon_art_root_xsk_bech32();
let blob = encrypt_root_xprv_bech32(&bech, "hunter2").unwrap();
let decrypted = decrypt_root_xprv_bech32(&blob, "hunter2").unwrap();
assert_eq!(&*decrypted as &str, bech.as_str());
// Imported RootKey must derive to the same address as the
// mnemonic path (proves bech32 round trip preserves entropy).
let root_a = Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap();
let addr_a = aldabra_core::derive_base_address(
&root_a,
aldabra_core::Network::Mainnet,
0,
0,
)
.unwrap();
let root_b = RootKey::from_root_xsk_bech32(&decrypted).unwrap();
let addr_b = aldabra_core::derive_base_address(
&root_b,
aldabra_core::Network::Mainnet,
0,
0,
)
.unwrap();
assert_eq!(
addr_a, addr_b,
"xprv import must derive the same address as mnemonic import"
);
}
#[test]
fn root_xprv_rejects_garbage() {
match RootKey::from_root_xsk_bech32("clearly not bech32") {
Ok(_) => panic!("expected error"),
Err(e) => assert!(e.to_string().to_lowercase().contains("bech32")),
}
}
}

View file

@ -61,14 +61,19 @@ async fn run() -> Result<()> {
// CLI mode flags — all out-of-band, before the MCP transport
// takes over stdio.
//
// `--generate-mnemonic` — print a fresh phrase, exit. No disk write.
// `--bootstrap` — paste an existing phrase, encrypt, derive.
// `--bootstrap-new` — generate, display, encrypt, derive (one shot).
// (none) — load existing, start MCP server.
// `--generate-mnemonic` — print a fresh phrase, exit. No disk write.
// `--bootstrap` — paste an existing phrase, encrypt, derive.
// `--bootstrap-new` — generate, display, encrypt, derive (one shot).
// `--bootstrap-from-xprv` — paste a root_xsk1... bech32 (cnode root.prv,
// cardano-address output), encrypt, derive.
// Power-user import path for keys that came
// from outside the BIP-39 mnemonic flow.
// (none) — load existing, start MCP server.
let args: Vec<String> = std::env::args().collect();
let generate_only = args.iter().any(|a| a == "--generate-mnemonic");
let bootstrap_only = args.iter().any(|a| a == "--bootstrap");
let bootstrap_new = args.iter().any(|a| a == "--bootstrap-new");
let bootstrap_from_xprv = args.iter().any(|a| a == "--bootstrap-from-xprv");
if generate_only {
bootstrap::print_fresh_mnemonic()?;
@ -76,22 +81,28 @@ async fn run() -> Result<()> {
}
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
let xprv_path = bootstrap::root_xprv_path(&cfg.data_dir);
let any_key_exists = mnemonic_path.exists() || xprv_path.exists();
// L-2 audit fix: scope `root` to a block so its XPrv drops + wipes
// as soon as we've extracted the keys we need.
let (payment_key, stake_key, address) = {
let root = if bootstrap_new {
bootstrap::generate_and_save_root_key(&cfg.data_dir)?
} else if mnemonic_path.exists() || bootstrap_only {
// Both branches load (or create on the bootstrap path).
// `load_or_create_root_key` itself decides which based on
// whether `mnemonic.age` exists.
} else if bootstrap_from_xprv {
bootstrap::import_root_xprv(&cfg.data_dir)?
} else if any_key_exists || bootstrap_only {
// Loads existing (or runs the mnemonic-paste flow on
// first-run with --bootstrap). load_or_create_root_key
// itself picks between mnemonic.age and root-xprv.age.
bootstrap::load_or_create_root_key(&cfg.data_dir)?
} else {
anyhow::bail!(
"no mnemonic at {}. run `aldabra --bootstrap` (paste existing) \
or `aldabra --bootstrap-new` (generate fresh) first.",
mnemonic_path.display()
"no key at {}. run one of:\n \
`aldabra --bootstrap` (paste existing 24-word phrase)\n \
`aldabra --bootstrap-new` (generate a fresh wallet)\n \
`aldabra --bootstrap-from-xprv` (paste a root_xsk1... bech32)",
cfg.data_dir.display()
);
};