aldabra/crates/aldabra-mcp/src/bootstrap.rs
Sulkta b69d891a2b chore: strip audit-ticket prefixes from code comments
Drops the ~60 ticket-prefix comments (CRIT-N, HIGH-N, MED-N, LOW-N,
L-N, M-N, AUDIT-N, PLUTUS-N, "audit fix (date):", "Phase N" labels,
"Adversarial-review fix:") that had accumulated in inline + doc
comments over several audit cycles. Where the surrounding prose
still carried useful WHY context it gets kept and tightened; where
the ticket WAS the comment it gets dropped entirely.

No logic, no renames, no behavior change. Audit history lives in
commit messages and the audits/ tree where it belongs — eternal
comments don't need to mirror it.

Net 138 LOC shorter. 253 tests pass, no new clippy or fmt warnings.
2026-05-12 14:42:13 -07:00

415 lines
16 KiB
Rust

//! First-run mnemonic bootstrap + subsequent unlock.
//!
//! On startup, the daemon expects to find an age-encrypted mnemonic at
//! `$ALDABRA_DATA/mnemonic.age`. If it doesn't exist, we run a one-time
//! interactive bootstrap: prompt the user to paste a 24-word mnemonic,
//! prompt for an encryption passphrase, and write the encrypted file.
//! On subsequent runs we just prompt for the passphrase, decrypt, and
//! derive the root key.
//!
//! ## Memory hygiene
//!
//! - `Mnemonic` (in `aldabra-core`) zeroizes the entropy on drop.
//! - The decrypted phrase is held in a `Zeroizing<String>` for the
//! ~milliseconds between decrypt and `Mnemonic::from_phrase`.
//! - Passphrases pass through `age::secrecy::SecretString`, which
//! zeroizes its internal buffer on drop.
//!
//! Hygiene is best-effort. Live RAM in this process is the security
//! boundary; if the host is hostile, no amount of zeroize helps. The
//! threat model here is post-mortem dumps + swap leaks, not active
//! attackers.
use std::fs;
use std::io::{BufRead, Read, Write};
use std::path::Path;
use age::secrecy::SecretString;
use age::{Decryptor, Encryptor};
use aldabra_core::{Mnemonic, RootKey};
use anyhow::{anyhow, Context, Result};
use zeroize::Zeroizing;
/// Atomically create a file with `0o600` permissions and write the
/// payload. Avoids the `fs::write` + `chmod` TOCTOU window where the
/// file briefly exists at default umask (often `0o644`) before the
/// chmod tightens it.
#[cfg(unix)]
fn write_owner_only(path: &Path, payload: &[u8]) -> Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {}", path.display()))?;
f.write_all(payload)?;
f.sync_all().ok();
Ok(())
}
#[cfg(not(unix))]
fn write_owner_only(path: &Path, payload: &[u8]) -> Result<()> {
fs::write(path, payload).with_context(|| format!("writing {}", path.display()))
}
/// Read `ALDABRA_PASSPHRASE` env into a `Zeroizing<String>` so the
/// in-process copy gets wiped on drop. The env block itself isn't
/// zeroizable — documented headless tradeoff.
fn passphrase_from_env() -> Option<Zeroizing<String>> {
std::env::var("ALDABRA_PASSPHRASE").ok().map(Zeroizing::new)
}
fn prompt_or_env_passphrase(confirm: bool) -> Result<Zeroizing<String>> {
if let Some(p) = passphrase_from_env() {
return Ok(p);
}
let p = Zeroizing::new(rpassword::prompt_password("set encryption passphrase: ")?);
if confirm {
let c = Zeroizing::new(rpassword::prompt_password("confirm passphrase: ")?);
if *p != *c {
return Err(anyhow!("passphrases did not match — re-run to retry"));
}
}
Ok(p)
}
fn unlock_passphrase() -> Result<Zeroizing<String>> {
if let Some(p) = passphrase_from_env() {
return Ok(p);
}
Ok(Zeroizing::new(rpassword::prompt_password("passphrase: ")?))
}
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>> {
let pp = SecretString::new(passphrase.to_string());
let encryptor = Encryptor::with_user_passphrase(pp);
let mut encrypted = Vec::with_capacity(phrase.len() + 256);
let mut writer = encryptor
.wrap_output(&mut encrypted)
.context("age: wrap_output")?;
writer.write_all(phrase.as_bytes())?;
writer.finish().context("age: finish writer")?;
Ok(encrypted)
}
/// Decrypt an age-encrypted mnemonic blob with a passphrase. Returns
/// the phrase wrapped in [`Zeroizing`] so it gets wiped from RAM when
/// dropped.
pub fn decrypt_mnemonic(blob: &[u8], passphrase: &str) -> Result<Zeroizing<String>> {
let pp = SecretString::new(passphrase.to_string());
let decryptor = match Decryptor::new(blob).context("age: parse header")? {
Decryptor::Passphrase(d) => d,
Decryptor::Recipients(_) => {
return Err(anyhow!(
"expected passphrase-encrypted age file, got recipients-encrypted"
))
}
};
let mut reader = decryptor
.decrypt(&pp, None)
.context("age: passphrase rejected or file corrupt")?;
let mut bytes = Zeroizing::new(Vec::with_capacity(256));
reader.read_to_end(&mut bytes)?;
let phrase = std::str::from_utf8(&bytes)
.context("decrypted mnemonic is not valid utf-8")?
.to_string();
Ok(Zeroizing::new(phrase))
}
/// Path of the encrypted mnemonic for a given data dir.
pub fn mnemonic_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(MNEMONIC_FILENAME)
}
/// 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 mn_path = mnemonic_path(data_dir);
let xprv_path = root_xprv_path(data_dir);
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)?;
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.
/// Read-only — does not touch disk. The user writes the phrase down
/// (cold metal, paper, whatever) then re-runs `aldabra --bootstrap`
/// to import it, OR uses `aldabra --bootstrap-new` for one-shot
/// generate-and-encrypt.
pub fn print_fresh_mnemonic() -> Result<()> {
let (_mnemonic, phrase) = Mnemonic::generate()?;
eprintln!("================ ALDABRA: NEW 24-WORD MNEMONIC ================");
eprintln!();
eprintln!("{}", phrase.as_str());
eprintln!();
eprintln!("WRITE THIS DOWN. It is the ONLY recovery path for this wallet.");
eprintln!("Anyone with this phrase can spend the wallet's funds.");
eprintln!();
eprintln!("To import: run `aldabra --bootstrap` and paste this phrase.");
eprintln!("===============================================================");
Ok(())
}
/// Generate a fresh mnemonic, display it for the user to write down,
/// then encrypt + persist it. Returns the derived [`RootKey`] so the
/// caller can continue with address derivation. Combines what
/// `print_fresh_mnemonic` + the import path of
/// `load_or_create_root_key` would do separately.
pub fn generate_and_save_root_key(data_dir: &Path) -> Result<RootKey> {
let path = mnemonic_path(data_dir);
if path.exists() {
return Err(anyhow!(
"mnemonic already exists at {} — refusing to overwrite. \
remove the file or use a different ALDABRA_DATA dir.",
path.display()
));
}
fs::create_dir_all(data_dir).with_context(|| format!("creating {}", data_dir.display()))?;
let (mnemonic, phrase) = Mnemonic::generate()?;
eprintln!("================ ALDABRA: NEW 24-WORD MNEMONIC ================");
eprintln!();
eprintln!("{}", phrase.as_str());
eprintln!();
eprintln!("WRITE THIS DOWN. It is the ONLY recovery path for this wallet.");
eprintln!("Anyone with this phrase can spend the wallet's funds.");
eprintln!("===============================================================");
eprintln!();
let passphrase = prompt_or_env_passphrase(true)?;
let blob = encrypt_mnemonic(&phrase, &passphrase)?;
write_owner_only(&path, &blob)?;
eprintln!("aldabra: mnemonic encrypted to {}", path.display());
Ok(mnemonic.into_root_key()?)
}
#[cfg(test)]
mod tests {
use super::*;
const ABANDON_ART: &str = concat!(
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon art",
);
#[test]
fn encrypt_decrypt_round_trip() {
let blob = encrypt_mnemonic(ABANDON_ART, "hunter2").unwrap();
let decrypted = decrypt_mnemonic(&blob, "hunter2").unwrap();
assert_eq!(&*decrypted as &str, ABANDON_ART);
}
#[test]
fn wrong_passphrase_fails() {
let blob = encrypt_mnemonic(ABANDON_ART, "hunter2").unwrap();
let result = decrypt_mnemonic(&blob, "wrong");
assert!(result.is_err(), "decrypt with wrong passphrase should fail");
}
#[test]
fn ciphertext_differs_from_plaintext() {
let blob = encrypt_mnemonic(ABANDON_ART, "hunter2").unwrap();
// Sanity — make sure we're actually encrypting, not echoing.
let cipher_str = String::from_utf8_lossy(&blob);
assert!(!cipher_str.contains("abandon"));
}
#[test]
fn random_passphrases_produce_different_ciphertexts() {
// Same plaintext + different passphrases must not produce the
// same ciphertext (no deterministic-key reuse).
let a = encrypt_mnemonic(ABANDON_ART, "alpha").unwrap();
let b = encrypt_mnemonic(ABANDON_ART, "beta").unwrap();
assert_ne!(a, b);
}
#[test]
fn same_passphrase_produces_different_ciphertexts() {
// age uses random salt — repeat encryption with the SAME
// passphrase must still produce different ciphertexts. This
// is a salt sanity check.
let a = encrypt_mnemonic(ABANDON_ART, "hunter2").unwrap();
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")),
}
}
}