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 Sulkta'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:
Sulkta 2026-05-05 06:38:01 -07:00
parent c57316a4fb
commit 2ff007ccaf
3 changed files with 275 additions and 45 deletions

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 the no-delete-crypto-keys policy).
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()
);
};