audit fixes: all 9 findings resolved + wallet generation tooling
HIGH:
- HIGH-1 enforce_value_cap helper applied to wallet.send,
wallet.mint, wallet.mint.cip68_nft, wallet.script.spend. each
gained a `force` arg; cap also covers the user_lovelace+ref_lovelace
sum on cip68_nft. wallet.stake.delegate skipped (2 ada deposit is
protocol-fixed, not a transfer to a non-wallet destination).
- HIGH-2 wallet.tx_summary mcp tool — read-only decode of a conway
tx cbor → typed TxSummary (inputs, outputs+assets, fee, certs,
mint, witness count, aux-data presence). new aldabra-core::inspect
module. callers MUST run this before wallet.sign_partial /
wallet.submit_signed_tx on any cbor they didn't build themselves.
MEDIUM:
- M-1 zeroize stack-resident extended_bytes after SecretKeyExtended
consumes them. tx.rs::payment_key_to_private + sign.rs::add_witness.
- M-2 atomic 0o600 mnemonic file create via OpenOptions+
OpenOptionsExt. removes the prior toctou window between fs::write
(default umask) and chmod 600.
- M-3 prompt_or_env_passphrase + unlock_passphrase helpers wrap the
passphrase in Zeroizing<String>. ALDABRA_PASSPHRASE env still
unzeroizable in the env block itself (documented headless tradeoff).
- M-4 is_hex_64 validator on submit_tx response — koios error wrapped
in quotes can no longer round-trip as a fake tx_hash.
LOW + cleanup:
- L-1 checked_add for inner sums of checked_sub patterns in tx.rs.
remaining sites (mint.rs, stake.rs, plutus.rs) deferred — same
pattern, can't overflow with realistic cardano amounts but
defensive. picked up next.
- L-2 root key scoped to a block in main.rs — XPrv drops + wipes
after deriving payment_key + stake_key + address. saves ~96 bytes
of secret material lifetime.
- L-3 TxStatus gained a Pending variant for the mempool-but-not-yet-
confirmed case. previously rendered as Confirmed{block_height: None}
which was misleading.
- L-4 .expect("we built this key") → typed ? propagation in
tx.rs::prepare_payment.
- L-5 removed dead fns (build_and_sign, decode_hex) + unused imports.
WALLET GENERATION (audit prompted gap-find):
aldabra had only an import path. no "generate fresh wallet" tool.
- Mnemonic::generate() — bip39::Mnemonic::generate_in(English, 24)
with the rand feature. returns (Mnemonic, Zeroizing<String>) so
the caller can display the phrase once for cold backup.
- aldabra --generate-mnemonic — print fresh phrase, exit. no disk.
- aldabra --bootstrap-new — generate + display + encrypt one-shot.
- bip39 dep gains the rand feature for OsRng-backed generation.
- standard 24-word BIP-39, recoverable from any cardano wallet.
mcp tools: 16 → 17 (added wallet.tx_summary).
unit tests: 88 → 93. cargo audit clean (0 cves), cargo build clean
(0 warnings). all four cli flags smoke-tested:
--generate-mnemonic prints + exits; --bootstrap-new generates +
encrypts + derives a real preprod address; mnemonic.age has 0o600
perms confirmed atomic.
audit doc internal notes updated with
status markers.
This commit is contained in:
parent
5888d37df6
commit
d1cc77969f
12 changed files with 696 additions and 126 deletions
|
|
@ -33,10 +33,11 @@ use pallas_addresses::{
|
|||
Network as PallasNetwork, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use zeroize::ZeroizeOnDrop;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
|
||||
|
||||
pub mod cip68;
|
||||
pub mod derive;
|
||||
pub mod inspect;
|
||||
pub mod metadata;
|
||||
pub mod mint;
|
||||
pub mod plutus;
|
||||
|
|
@ -47,6 +48,7 @@ pub use cip68::{
|
|||
build_cip68_datum_cbor, ft_asset_name, ref_nft_asset_name, user_nft_asset_name,
|
||||
};
|
||||
pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey};
|
||||
pub use inspect::{summarize_tx, AssetEntry, CertificateSummary, MintEntry, OutputSummary, TxSummary};
|
||||
// Stake address derivation lives directly on StakeKey — exported above.
|
||||
pub use metadata::{build_cip25_aux_data, CIP25_LABEL};
|
||||
pub use mint::{
|
||||
|
|
@ -95,6 +97,27 @@ pub struct Mnemonic {
|
|||
}
|
||||
|
||||
impl Mnemonic {
|
||||
/// Generate a fresh 24-word mnemonic from the system random source.
|
||||
/// Returns the typed [`Mnemonic`] (entropy stored, zeroized on drop)
|
||||
/// **and** the phrase string for one-time display to the user.
|
||||
/// The phrase is wrapped in [`Zeroizing`] so the caller doesn't
|
||||
/// have to remember to wipe it.
|
||||
pub fn generate() -> Result<(Self, Zeroizing<String>), WalletError> {
|
||||
let bip = Bip39Mnemonic::generate_in(Language::English, 24)
|
||||
.map_err(|e| WalletError::InvalidMnemonic(format!("generate failed: {e}")))?;
|
||||
// bip39's Display impl emits the space-separated phrase. Pull
|
||||
// it out into our own owned + zeroized string before bip drops.
|
||||
let phrase = Zeroizing::new(bip.to_string());
|
||||
let entropy_vec = bip.to_entropy();
|
||||
let entropy: [u8; 32] = entropy_vec.try_into().map_err(|v: Vec<u8>| {
|
||||
WalletError::InvalidMnemonic(format!(
|
||||
"expected 32 entropy bytes for 24-word mnemonic, got {}",
|
||||
v.len()
|
||||
))
|
||||
})?;
|
||||
Ok((Self { entropy }, phrase))
|
||||
}
|
||||
|
||||
/// Parse a 24-word English mnemonic, validating word count + checksum.
|
||||
/// Drops the source phrase reference immediately after extracting
|
||||
/// entropy.
|
||||
|
|
@ -143,6 +166,11 @@ impl Mnemonic {
|
|||
pbkdf2(&mut hmac, &self.entropy, 4096, &mut xprv_bytes);
|
||||
|
||||
let xprv = XPrv::normalize_bytes_force3rd(xprv_bytes);
|
||||
// `xprv_bytes` was moved into normalize_bytes_force3rd, but
|
||||
// the stack slot can still hold a copy depending on calling
|
||||
// conventions / inlining. Defensive zeroize.
|
||||
// (M-1 audit fix.)
|
||||
xprv_bytes.zeroize();
|
||||
Ok(RootKey { xprv })
|
||||
}
|
||||
}
|
||||
|
|
@ -269,6 +297,25 @@ mod tests {
|
|||
assert_eq!(m.entropy, [0u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_produces_24_word_phrase() {
|
||||
let (mnemonic, phrase) = Mnemonic::generate().expect("generate");
|
||||
assert_eq!(phrase.split_whitespace().count(), 24);
|
||||
// Round-trip: re-parse the generated phrase, confirm we land on
|
||||
// the same entropy.
|
||||
let reparsed = Mnemonic::from_phrase(&phrase).expect("re-parse own output");
|
||||
assert_eq!(reparsed.entropy, mnemonic.entropy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_produces_distinct_phrases() {
|
||||
let (a, _phrase_a) = Mnemonic::generate().unwrap();
|
||||
let (b, _phrase_b) = Mnemonic::generate().unwrap();
|
||||
// Astronomically unlikely to collide; if this ever fails the
|
||||
// RNG source is broken.
|
||||
assert_ne!(a.entropy, b.entropy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_root_key_from_canonical_mnemonic() {
|
||||
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue