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:
parent
057f623312
commit
e712f370f0
3 changed files with 275 additions and 45 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue