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

@ -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()
);
};