diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index 6ed077a..c221c7a 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -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 { + 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//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 { + 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::::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//root.prv` files store. + /// **Sensitive output** — anyone with this string can spend the + /// wallet's funds. + pub fn to_root_xsk_bech32(&self) -> Result { + 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. diff --git a/crates/aldabra-mcp/src/bootstrap.rs b/crates/aldabra-mcp/src/bootstrap.rs index e12aa58..ada2d95 100644 --- a/crates/aldabra-mcp/src/bootstrap.rs +++ b/crates/aldabra-mcp/src/bootstrap.rs @@ -85,6 +85,14 @@ fn unlock_passphrase() -> Result> { 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//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> { @@ -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> { + encrypt_mnemonic(bech32_str, passphrase) +} + +/// Decrypt an age-encrypted root xprv blob. Returns the bech32 +/// `root_xsk1...` string in `Zeroizing` so it gets wiped +/// when dropped. +pub fn decrypt_root_xprv_bech32(blob: &[u8], passphrase: &str) -> Result> { + 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 { - 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 { + 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")), + } + } } diff --git a/crates/aldabra-mcp/src/main.rs b/crates/aldabra-mcp/src/main.rs index bc50edf..ceec207 100644 --- a/crates/aldabra-mcp/src/main.rs +++ b/crates/aldabra-mcp/src/main.rs @@ -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 = 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() ); };