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

@ -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.