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
|
|
@ -130,6 +130,13 @@ fn parse_u64(s: &str, field: &str) -> Result<u64, ChainError> {
|
|||
.map_err(|e| ChainError::Decode(format!("{field}: {e} (got {s:?})")))
|
||||
}
|
||||
|
||||
/// True iff `s` is exactly 64 hex chars — what a Cardano tx hash must
|
||||
/// look like. Used by `submit_tx` to validate the response wasn't an
|
||||
/// error message wrapped in quotes.
|
||||
fn is_hex_64(s: &str) -> bool {
|
||||
s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn asset_key(policy_id: &str, asset_name_hex: &str) -> String {
|
||||
let mut k = String::with_capacity(policy_id.len() + asset_name_hex.len());
|
||||
k.push_str(policy_id);
|
||||
|
|
@ -205,18 +212,30 @@ impl ChainBackend for KoiosClient {
|
|||
.await
|
||||
.map_err(|e| ChainError::Decode(e.to_string()))?;
|
||||
// Koios returns the tx hash as a quoted JSON string. Strip the
|
||||
// surrounding quotes if present.
|
||||
Ok(body.trim().trim_matches('"').to_string())
|
||||
// surrounding quotes if present, then validate the result is
|
||||
// exactly 64 hex chars.
|
||||
// M-4 audit fix: previously a quoted error message would
|
||||
// round-trip as a fake tx_hash.
|
||||
let hash = body.trim().trim_matches('"').to_string();
|
||||
if !is_hex_64(&hash) {
|
||||
return Err(ChainError::Decode(format!(
|
||||
"submittx returned non-hash response: {body:?}"
|
||||
)));
|
||||
}
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError> {
|
||||
let body = TxHashesBody { tx_hashes: vec![tx_hash] };
|
||||
let raw: Vec<KoiosTxInfo> = self.post_json("tx_info", &body).await?;
|
||||
match raw.into_iter().next() {
|
||||
Some(info) => Ok(TxStatus::Confirmed {
|
||||
block_height: info.block_height,
|
||||
epoch: info.epoch_no,
|
||||
}),
|
||||
Some(info) => match info.block_height {
|
||||
Some(h) => Ok(TxStatus::Confirmed {
|
||||
block_height: h,
|
||||
epoch: info.epoch_no,
|
||||
}),
|
||||
None => Ok(TxStatus::Pending),
|
||||
},
|
||||
None => Ok(TxStatus::NotFound),
|
||||
}
|
||||
}
|
||||
|
|
@ -342,6 +361,16 @@ mod tests {
|
|||
assert_eq!(assets.get("ee0a1234deadbeef"), Some(&123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hex_64_validates_tx_hash_shape() {
|
||||
assert!(is_hex_64(&"a".repeat(64)));
|
||||
assert!(is_hex_64(&"ABCDef0123456789".repeat(4)));
|
||||
assert!(!is_hex_64(&"a".repeat(63)), "wrong length");
|
||||
assert!(!is_hex_64(&"a".repeat(65)), "wrong length");
|
||||
assert!(!is_hex_64(&"z".repeat(64)), "non-hex chars");
|
||||
assert!(!is_hex_64("invalid tx"), "error message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_u64_rejects_garbage() {
|
||||
let err = parse_u64("not-a-number", "test").unwrap_err();
|
||||
|
|
@ -384,15 +413,19 @@ mod tests {
|
|||
#[test]
|
||||
fn tx_status_serializes_with_tag() {
|
||||
let confirmed = TxStatus::Confirmed {
|
||||
block_height: Some(100),
|
||||
block_height: 100,
|
||||
epoch: Some(5),
|
||||
};
|
||||
let json = serde_json::to_string(&confirmed).unwrap();
|
||||
assert!(json.contains("\"status\":\"confirmed\""));
|
||||
assert!(json.contains("\"block_height\":100"));
|
||||
|
||||
let pending = TxStatus::NotFound;
|
||||
let pending = TxStatus::Pending;
|
||||
let json = serde_json::to_string(&pending).unwrap();
|
||||
assert!(json.contains("\"status\":\"pending\""));
|
||||
|
||||
let nf = TxStatus::NotFound;
|
||||
let json = serde_json::to_string(&nf).unwrap();
|
||||
assert!(json.contains("\"status\":\"not_found\""));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,13 +65,19 @@ pub struct Balance {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum TxStatus {
|
||||
/// Confirmed on-chain. `block_height` and `epoch` are populated
|
||||
/// when Koios returns them.
|
||||
/// Confirmed on-chain with a block height.
|
||||
Confirmed {
|
||||
block_height: Option<u64>,
|
||||
block_height: u64,
|
||||
epoch: Option<u64>,
|
||||
},
|
||||
/// Not (yet) seen by the chain backend.
|
||||
/// Koios returned a record but no block_height — the tx is in
|
||||
/// the chain backend's mempool but not yet in a confirmed block.
|
||||
/// (L-3 audit fix: previously this case was lumped in with
|
||||
/// Confirmed and rendered as `Confirmed { block_height: None }`,
|
||||
/// which is misleading.)
|
||||
Pending,
|
||||
/// Not seen by the chain backend (not in mempool, not confirmed,
|
||||
/// possibly never submitted or rejected).
|
||||
NotFound,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue