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
328
crates/aldabra-core/src/inspect.rs
Normal file
328
crates/aldabra-core/src/inspect.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
//! Decode + summarize a Conway-era tx CBOR for human review.
|
||||
//!
|
||||
//! Used by `wallet.tx_summary` (Phase 4 / audit HIGH-2 fix). Before
|
||||
//! a caller hands a pre-built CBOR to `wallet.sign_partial` or
|
||||
//! `wallet.submit_signed_tx`, they should pull a summary through here
|
||||
//! and review what the tx actually does — what's being spent, where
|
||||
//! funds are going, what's being minted, what certs are present.
|
||||
//!
|
||||
//! No I/O. Pure decode → typed summary. Caller serializes to JSON.
|
||||
|
||||
use pallas_primitives::conway::{
|
||||
Certificate, PseudoTransactionOutput, StakeCredential, TransactionOutput, Tx, Value,
|
||||
};
|
||||
use pallas_primitives::Fragment;
|
||||
use pallas_traverse::ComputeHash;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::WalletError;
|
||||
|
||||
/// Top-level summary of a Conway-era transaction.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TxSummary {
|
||||
/// Body hash (matches what witnesses sign). 64-char hex.
|
||||
pub tx_hash: String,
|
||||
/// Number of inputs being consumed.
|
||||
pub num_inputs: usize,
|
||||
/// One entry per output, in tx order.
|
||||
pub outputs: Vec<OutputSummary>,
|
||||
/// Fee in lovelace.
|
||||
pub fee_lovelace: u64,
|
||||
/// 0 = testnet header byte, 1 = mainnet header byte. None means
|
||||
/// the field wasn't set (rare in practice).
|
||||
pub network_id: Option<u8>,
|
||||
/// `valid_from_slot` if set (tx invalid before this slot).
|
||||
pub valid_from_slot: Option<u64>,
|
||||
/// `invalid_from_slot` if set (tx invalid at-or-after this slot).
|
||||
pub invalid_from_slot: Option<u64>,
|
||||
/// Certificates in the tx body. Common: stake registration,
|
||||
/// stake delegation, DRep ops.
|
||||
pub certificates: Vec<CertificateSummary>,
|
||||
/// Mint actions. Positive amounts mint, negative amounts burn.
|
||||
pub mint: Vec<MintEntry>,
|
||||
/// Number of `VKeyWitness` entries currently in the witness set.
|
||||
/// 0 = unsigned. Each call to `wallet.sign_partial` adds one.
|
||||
pub vkey_witness_count: usize,
|
||||
/// Whether the tx body declares an `auxiliary_data_hash`. If true,
|
||||
/// `auxiliary_data_present` says whether the actual aux data
|
||||
/// rides along (a hash without data is malformed).
|
||||
pub auxiliary_data_hash_set: bool,
|
||||
pub auxiliary_data_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct OutputSummary {
|
||||
/// Hex of the raw address bytes — caller can decode bech32 if they
|
||||
/// want a friendlier render. Avoids embedding pallas-addresses
|
||||
/// here.
|
||||
pub address_hex: String,
|
||||
pub lovelace: u64,
|
||||
/// Native assets riding with this output. Empty for ADA-only.
|
||||
pub assets: Vec<AssetEntry>,
|
||||
/// True if the output carries an inline datum (Plutus / CIP-68).
|
||||
pub has_inline_datum: bool,
|
||||
/// True if the output declares a reference script.
|
||||
pub has_reference_script: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AssetEntry {
|
||||
pub policy_id_hex: String,
|
||||
pub asset_name_hex: String,
|
||||
pub quantity: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MintEntry {
|
||||
pub policy_id_hex: String,
|
||||
pub asset_name_hex: String,
|
||||
/// Negative for burn.
|
||||
pub quantity: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum CertificateSummary {
|
||||
StakeRegistration {
|
||||
credential_hex: String,
|
||||
},
|
||||
StakeDeregistration {
|
||||
credential_hex: String,
|
||||
},
|
||||
StakeDelegation {
|
||||
credential_hex: String,
|
||||
pool_id_hex: String,
|
||||
},
|
||||
/// Catch-all for cert types we haven't surfaced explicitly yet
|
||||
/// (pool registration, DRep ops, Voltaire-era combos).
|
||||
Other {
|
||||
debug: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn credential_hex(c: &StakeCredential) -> String {
|
||||
match c {
|
||||
StakeCredential::AddrKeyhash(h) => hex(h.as_ref()),
|
||||
StakeCredential::ScriptHash(h) => hex(h.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cert_summary(c: &Certificate) -> CertificateSummary {
|
||||
match c {
|
||||
Certificate::StakeRegistration(cred) => CertificateSummary::StakeRegistration {
|
||||
credential_hex: credential_hex(cred),
|
||||
},
|
||||
Certificate::StakeDeregistration(cred) => CertificateSummary::StakeDeregistration {
|
||||
credential_hex: credential_hex(cred),
|
||||
},
|
||||
Certificate::StakeDelegation(cred, pool) => CertificateSummary::StakeDelegation {
|
||||
credential_hex: credential_hex(cred),
|
||||
pool_id_hex: hex(pool.as_ref()),
|
||||
},
|
||||
other => CertificateSummary::Other {
|
||||
debug: format!("{other:?}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn output_summary(out: &TransactionOutput) -> OutputSummary {
|
||||
match out {
|
||||
PseudoTransactionOutput::Legacy(legacy) => {
|
||||
let (lovelace, assets) = decode_alonzo_value(&legacy.amount);
|
||||
OutputSummary {
|
||||
address_hex: hex(&legacy.address),
|
||||
lovelace,
|
||||
assets,
|
||||
has_inline_datum: false,
|
||||
has_reference_script: false,
|
||||
}
|
||||
}
|
||||
PseudoTransactionOutput::PostAlonzo(po) => {
|
||||
let has_inline = matches!(
|
||||
po.datum_option.as_ref(),
|
||||
Some(pallas_primitives::conway::PseudoDatumOption::Data(_))
|
||||
);
|
||||
let has_ref = po.script_ref.is_some();
|
||||
let (lovelace, assets) = decode_conway_value(&po.value);
|
||||
OutputSummary {
|
||||
address_hex: hex(&po.address),
|
||||
lovelace,
|
||||
assets,
|
||||
has_inline_datum: has_inline,
|
||||
has_reference_script: has_ref,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_conway_value(v: &Value) -> (u64, Vec<AssetEntry>) {
|
||||
match v {
|
||||
Value::Coin(c) => (*c, vec![]),
|
||||
Value::Multiasset(coin, multi) => {
|
||||
let mut assets = Vec::new();
|
||||
for (policy, names) in multi.iter() {
|
||||
for (name, qty) in names.iter() {
|
||||
let name_bytes: &[u8] = name.as_ref();
|
||||
let qty_u64: u64 = (*qty).into();
|
||||
assets.push(AssetEntry {
|
||||
policy_id_hex: hex(policy.as_ref()),
|
||||
asset_name_hex: hex(name_bytes),
|
||||
quantity: qty_u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
(*coin, assets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_alonzo_value(v: &pallas_primitives::alonzo::Value) -> (u64, Vec<AssetEntry>) {
|
||||
use pallas_primitives::alonzo::Value as AV;
|
||||
match v {
|
||||
AV::Coin(c) => (*c, vec![]),
|
||||
AV::Multiasset(coin, multi) => {
|
||||
let mut assets = Vec::new();
|
||||
for (policy, names) in multi.iter() {
|
||||
for (name, qty) in names.iter() {
|
||||
let name_bytes: &[u8] = name.as_ref();
|
||||
assets.push(AssetEntry {
|
||||
policy_id_hex: hex(policy.as_ref()),
|
||||
asset_name_hex: hex(name_bytes),
|
||||
quantity: *qty,
|
||||
});
|
||||
}
|
||||
}
|
||||
(*coin, assets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode + summarize a Conway-era tx CBOR. Caller serializes the
|
||||
/// returned struct to JSON.
|
||||
pub fn summarize_tx(cbor_bytes: &[u8]) -> Result<TxSummary, WalletError> {
|
||||
let tx = Tx::decode_fragment(cbor_bytes)
|
||||
.map_err(|e| WalletError::Derivation(format!("decode tx: {e}")))?;
|
||||
let body = &tx.transaction_body;
|
||||
let body_hash = body.compute_hash();
|
||||
|
||||
let outputs: Vec<OutputSummary> = body.outputs.iter().map(output_summary).collect();
|
||||
|
||||
let certificates: Vec<CertificateSummary> = body
|
||||
.certificates
|
||||
.as_ref()
|
||||
.map(|c| c.iter().map(cert_summary).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut mint_entries: Vec<MintEntry> = Vec::new();
|
||||
if let Some(mint) = body.mint.as_ref() {
|
||||
for (policy, names) in mint.iter() {
|
||||
for (name, qty) in names.iter() {
|
||||
let name_bytes: &[u8] = name.as_ref();
|
||||
mint_entries.push(MintEntry {
|
||||
policy_id_hex: hex(policy.as_ref()),
|
||||
asset_name_hex: hex(name_bytes),
|
||||
quantity: i64::from(*qty),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let vkey_witness_count = tx
|
||||
.transaction_witness_set
|
||||
.vkeywitness
|
||||
.as_ref()
|
||||
.map(|w| w.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let auxiliary_data_hash_set = body.auxiliary_data_hash.is_some();
|
||||
let auxiliary_data_present = matches!(
|
||||
tx.auxiliary_data,
|
||||
pallas_codec::utils::Nullable::Some(_)
|
||||
);
|
||||
|
||||
Ok(TxSummary {
|
||||
tx_hash: hex(body_hash.as_ref()),
|
||||
num_inputs: body.inputs.len(),
|
||||
outputs,
|
||||
fee_lovelace: body.fee,
|
||||
network_id: body.network_id.as_ref().map(|n| match n {
|
||||
pallas_primitives::conway::NetworkId::Testnet => 0u8,
|
||||
pallas_primitives::conway::NetworkId::Mainnet => 1u8,
|
||||
}),
|
||||
valid_from_slot: body.validity_interval_start,
|
||||
invalid_from_slot: body.ttl,
|
||||
certificates,
|
||||
mint: mint_entries,
|
||||
vkey_witness_count,
|
||||
auxiliary_data_hash_set,
|
||||
auxiliary_data_present,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tx::{build_unsigned_payment, InputUtxo, ProtocolParams};
|
||||
use crate::{Mnemonic, Network};
|
||||
|
||||
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",
|
||||
);
|
||||
|
||||
fn fixture_unsigned_payment_cbor() -> Vec<u8> {
|
||||
let root = Mnemonic::from_phrase(ABANDON_ART)
|
||||
.unwrap()
|
||||
.into_root_key()
|
||||
.unwrap();
|
||||
let change = crate::derive_base_address(&root, Network::Preprod, 0, 0).unwrap();
|
||||
let to = crate::derive_base_address(&root, Network::Preprod, 0, 1).unwrap();
|
||||
let utxos = vec![InputUtxo {
|
||||
tx_hash_hex: "deadbeef".repeat(8),
|
||||
output_index: 0,
|
||||
lovelace: 100_000_000,
|
||||
assets: Default::default(),
|
||||
}];
|
||||
let unsigned = build_unsigned_payment(
|
||||
Network::Preprod,
|
||||
&utxos,
|
||||
&change,
|
||||
&to,
|
||||
10_000_000,
|
||||
&ProtocolParams::default(),
|
||||
)
|
||||
.unwrap();
|
||||
crate::tx::hex_decode(&unsigned.cbor_hex).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarizes_simple_payment() {
|
||||
let cbor = fixture_unsigned_payment_cbor();
|
||||
let s = summarize_tx(&cbor).unwrap();
|
||||
assert_eq!(s.tx_hash.len(), 64);
|
||||
assert_eq!(s.num_inputs, 1);
|
||||
// Send + change = 2 outputs.
|
||||
assert_eq!(s.outputs.len(), 2);
|
||||
// Recipient gets 10M.
|
||||
assert!(s.outputs.iter().any(|o| o.lovelace == 10_000_000));
|
||||
assert!(s.fee_lovelace > 0);
|
||||
assert_eq!(s.vkey_witness_count, 0, "unsigned tx has no witnesses");
|
||||
assert!(s.certificates.is_empty());
|
||||
assert!(s.mint.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_cbor() {
|
||||
assert!(summarize_tx(b"not cbor").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@
|
|||
//! follow-ups. ExUnits today come from the caller.
|
||||
|
||||
use bech32::FromBase32;
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_primitives::Fragment;
|
||||
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction};
|
||||
|
||||
use crate::sign::add_witness;
|
||||
|
|
@ -96,20 +94,6 @@ fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
|
|||
Ok(Hash::<32>::new(out))
|
||||
}
|
||||
|
||||
fn decode_hex(s: &str) -> Result<Vec<u8>, WalletError> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err(WalletError::Derivation("hex string odd length".into()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
for i in (0..s.len()).step_by(2) {
|
||||
out.push(
|
||||
u8::from_str_radix(&s[i..i + 2], 16)
|
||||
.map_err(|_| WalletError::Derivation(format!("invalid hex: {s}")))?,
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn network_id_for(network: Network) -> u8 {
|
||||
match network {
|
||||
Network::Mainnet => 1,
|
||||
|
|
@ -363,6 +347,7 @@ mod tests {
|
|||
.expect("plutus spend builds + signs");
|
||||
assert!(cbor.len() > 200);
|
||||
|
||||
use pallas_primitives::Fragment;
|
||||
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
|
||||
.expect("decode plutus spend cbor");
|
||||
// Inputs include the locked UTXO + collateral.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use pallas_crypto::key::ed25519::SecretKeyExtended;
|
|||
use pallas_primitives::conway::{Tx, VKeyWitness};
|
||||
use pallas_primitives::Fragment;
|
||||
use pallas_traverse::ComputeHash;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::{PaymentKey, WalletError};
|
||||
|
||||
|
|
@ -46,9 +47,13 @@ pub fn add_witness(
|
|||
// encoder).
|
||||
let body_hash = tx.transaction_body.compute_hash();
|
||||
|
||||
let extended_bytes: [u8; 64] = payment_key.xprv().extended_secret_key();
|
||||
// M-1 audit fix: stack copy gets zeroized after from_bytes
|
||||
// consumes it.
|
||||
let mut extended_bytes: [u8; 64] = payment_key.xprv().extended_secret_key();
|
||||
let secret = SecretKeyExtended::from_bytes(extended_bytes)
|
||||
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?;
|
||||
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")));
|
||||
extended_bytes.zeroize();
|
||||
let secret = secret?;
|
||||
let signature = secret.sign(body_hash.as_ref());
|
||||
let pubkey = secret.public_key();
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ use pallas_crypto::key::ed25519::SecretKeyExtended;
|
|||
use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, StagingTransaction};
|
||||
use pallas_wallet::PrivateKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::{Network, PaymentKey, WalletError};
|
||||
|
||||
|
|
@ -280,11 +281,20 @@ fn hex_decode_32(s: &str) -> Result<[u8; 32], WalletError> {
|
|||
/// Convert a [`PaymentKey`] into a `pallas-wallet::PrivateKey` so
|
||||
/// `BuiltTransaction::sign` can consume it. The XPrv's first 64
|
||||
/// bytes are the extended secret; we reuse them directly.
|
||||
///
|
||||
/// **M-1 audit fix**: defensive zeroize of the stack-resident
|
||||
/// extended-secret bytes after `from_bytes` consumes them. The
|
||||
/// SecretKeyExtended itself zeroizes on drop (pallas-crypto handles
|
||||
/// that); this just covers the local stack copy that lingers between
|
||||
/// `extended_secret_key()` returning and `from_bytes` taking it by
|
||||
/// value.
|
||||
fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> {
|
||||
let xprv: &XPrv = payment.xprv();
|
||||
let extended: [u8; 64] = xprv.extended_secret_key();
|
||||
let mut extended: [u8; 64] = xprv.extended_secret_key();
|
||||
let secret = SecretKeyExtended::from_bytes(extended)
|
||||
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?;
|
||||
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")));
|
||||
extended.zeroize();
|
||||
let secret = secret?;
|
||||
Ok(PrivateKey::Extended(secret))
|
||||
}
|
||||
|
||||
|
|
@ -397,18 +407,6 @@ fn build_unsigned_bytes(
|
|||
Ok(built.tx_bytes.0)
|
||||
}
|
||||
|
||||
fn build_and_sign(
|
||||
staging: StagingTransaction,
|
||||
private: PrivateKey,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let built = staging
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?;
|
||||
let signed = built
|
||||
.sign(private)
|
||||
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
|
||||
Ok(signed.tx_bytes.0)
|
||||
}
|
||||
|
||||
/// Internal helper — runs the two-pass fee refinement and returns
|
||||
/// the final `BuiltTransaction` plus a `PaymentSummary` describing
|
||||
|
|
@ -495,12 +493,13 @@ fn prepare_payment(
|
|||
// worth of lovelace into change in that case.
|
||||
let change_must_exist = !change_assets.is_empty();
|
||||
|
||||
let (final_fee, final_change) = match total_in_lovelace.checked_sub(lovelace + real_fee) {
|
||||
// L-1 audit fix: checked_add for the inner sum so the outer
|
||||
// checked_sub is fully defensive against u64 overflow.
|
||||
let outflow = lovelace
|
||||
.checked_add(real_fee)
|
||||
.ok_or_else(|| WalletError::Derivation("lovelace + fee overflow".into()))?;
|
||||
let (final_fee, final_change) = match total_in_lovelace.checked_sub(outflow) {
|
||||
Some(c) if c >= params.min_utxo_lovelace || change_must_exist => {
|
||||
// change_must_exist + c < min_utxo: caller didn't bring
|
||||
// enough ADA to support a token-bearing change output.
|
||||
// Surface a clearer error than letting the chain reject
|
||||
// the tx for a sub-min output.
|
||||
if change_must_exist && c < params.min_utxo_lovelace {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient ADA for token-bearing change output: change={c} lovelace, min={}",
|
||||
|
|
@ -510,7 +509,12 @@ fn prepare_payment(
|
|||
(real_fee, c)
|
||||
}
|
||||
// ADA-only path with sub-min change — fold into fee.
|
||||
Some(c) => (real_fee + c, 0),
|
||||
Some(c) => (
|
||||
real_fee
|
||||
.checked_add(c)
|
||||
.ok_or_else(|| WalletError::Derivation("fee + change overflow".into()))?,
|
||||
0,
|
||||
),
|
||||
None => {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in_lovelace} lovelace={lovelace} fee={real_fee}"
|
||||
|
|
@ -536,28 +540,31 @@ fn prepare_payment(
|
|||
|
||||
// Re-shape the asset maps back into Vec<AssetSpec> for the
|
||||
// summary — easier for callers to display than a BTreeMap.
|
||||
// L-4 audit fix: replaced .expect() with proper error
|
||||
// propagation. Logic-bug paths (we built the key) become
|
||||
// typed errors instead of process-level panics.
|
||||
let send_assets_vec: Vec<AssetSpec> = target_assets
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let (p, n) = split_asset_key(k).expect("we built this key");
|
||||
AssetSpec {
|
||||
let (p, n) = split_asset_key(k)?;
|
||||
Ok::<AssetSpec, WalletError>(AssetSpec {
|
||||
policy_id_hex: p.to_string(),
|
||||
asset_name_hex: n.to_string(),
|
||||
quantity: *v,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<_, _>>()?;
|
||||
let change_assets_vec: Vec<AssetSpec> = change_assets
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let (p, n) = split_asset_key(k).expect("we built this key");
|
||||
AssetSpec {
|
||||
let (p, n) = split_asset_key(k)?;
|
||||
Ok::<AssetSpec, WalletError>(AssetSpec {
|
||||
policy_id_hex: p.to_string(),
|
||||
asset_name_hex: n.to_string(),
|
||||
quantity: *v,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let summary = PaymentSummary {
|
||||
tx_hash: hex_encode(&built.tx_hash.0),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue