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.
415 lines
16 KiB
Rust
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")),
|
|
}
|
|
}
|
|
}
|