fix: extend coverage for fuzz_block_verification

This commit is contained in:
Roman 2026-05-14 10:38:59 +08:00
parent 0aefa289d2
commit 64e8f335a3
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E

View File

@ -1,40 +1,135 @@
#![no_main] #![no_main]
//! Fuzz target: block hash integrity — three invariants unique to block-level validation.
//!
//! 1. **Hash integrity via `From<Block>` round-trip** — `HashableBlockData::from(block)`
//! must be lossless: re-deriving the hash from the converted value must reproduce the
//! hash that `into_pending_block` stored in `block.header.hash`. Catches any field
//! that is dropped or silently transformed by the `From` impl.
//!
//! 2. **Hash preimage completeness** — every header field of `HashableBlockData`
//! (`block_id`, `prev_block_hash`, `timestamp`) must affect the computed hash.
//! Verified by single-field mutations: changing one field must produce a different
//! hash. A hash that silently ignores a field allows an attacker to rewrite that
//! field without invalidating the block hash.
//!
//! 3. **Transaction-order commitment** — the hash must be order-sensitive. Reversing
//! a block's transaction list (when the first and last transactions differ bytewise)
//! must produce a different hash. A commutative hash (e.g., XOR of per-tx hashes)
//! would allow silent transaction reordering while the block hash remains valid.
//!
use common::block::{Block, HashableBlockData}; use arbitrary::{Arbitrary, Unstructured};
use common::block::HashableBlockData;
use fuzz_props::arbitrary_types::ArbHashableBlockData;
use libfuzzer_sys::fuzz_target; use libfuzzer_sys::fuzz_target;
use nssa::PrivateKey; use nssa::PrivateKey;
// A fixed, valid signing key used only to exercise the hash-computation path.
// The specific key value is irrelevant to hash correctness.
const DUMMY_KEY_BYTES: [u8; 32] = [1u8; 32]; const DUMMY_KEY_BYTES: [u8; 32] = [1u8; 32];
fuzz_target!(|data: &[u8]| { fuzz_target!(|data: &[u8]| {
let Ok(block) = borsh::from_slice::<Block>(data) else { let mut u = Unstructured::new(data);
let Ok(wrap) = ArbHashableBlockData::arbitrary(&mut u) else {
return; return;
}; };
let base = wrap.0;
let signing_key = PrivateKey::try_new(DUMMY_KEY_BYTES).expect("constant key is valid"); let signing_key = PrivateKey::try_new(DUMMY_KEY_BYTES).expect("constant key is valid");
let bedrock_parent_id = [0u8; 32]; let bedrock = [0u8; 32];
// Convert to hashable form twice so we can check determinism without // Compute the canonical hash for the base input.
// moving the value into the first call. let block = base.clone().into_pending_block(&signing_key, bedrock);
let hashable1 = HashableBlockData::from(block.clone()); let hash_base = block.header.hash;
let hashable2 = HashableBlockData::from(block.clone());
// INVARIANT: into_pending_block() must never panic regardless of fuzz input // ── INVARIANT 1: HashableBlockData::from(Block) is lossless ──────────────────
let recomputed1 = hashable1.into_pending_block(&signing_key, bedrock_parent_id); //
let hash1 = recomputed1.header.hash; // For blocks produced by `into_pending_block`, `header.hash` is the hash computed
// from the block's semantic fields. Converting back via `From<Block>` strips the
// header (computed hash, signature) and retains only the payload. Re-deriving the
// hash from the round-tripped value must reproduce the original hash.
//
// This is the hash-integrity check the old target deliberately skipped for adversarial
// inputs. For *programmatically-constructed* blocks it is fully assertable.
{
let roundtrip_hashable = HashableBlockData::from(block);
let hash_roundtrip = roundtrip_hashable
.into_pending_block(&signing_key, bedrock)
.header
.hash;
assert_eq!(
hash_base,
hash_roundtrip,
"INVARIANT VIOLATION [HashRoundTrip]: HashableBlockData::from(Block) is lossy — \
re-derived hash differs from the original (a field is dropped or transformed \
by the From impl, breaking hash integrity)"
);
}
// INVARIANT: hash derivation must be deterministic // ── INVARIANT 2a: block_id is included in the hash preimage ──────────────────
let recomputed2 = hashable2.into_pending_block(&signing_key, bedrock_parent_id); {
let hash2 = recomputed2.header.hash; let mut m = base.clone();
m.block_id = m.block_id.wrapping_add(1);
let hash_m = m.into_pending_block(&signing_key, bedrock).header.hash;
assert_ne!(
hash_base,
hash_m,
"INVARIANT VIOLATION [HashPreimage/block_id]: incrementing block_id did not \
change the block hash (block_id is absent from the hash preimage an attacker \
can change the block number without invalidating the hash)"
);
}
assert_eq!(hash1, hash2, "block hash is not deterministic"); // ── INVARIANT 2b: prev_block_hash is included in the hash preimage ───────────
{
let mut m = base.clone();
m.prev_block_hash.0[0] ^= 0xFF;
let hash_m = m.into_pending_block(&signing_key, bedrock).header.hash;
assert_ne!(
hash_base,
hash_m,
"INVARIANT VIOLATION [HashPreimage/prev_block_hash]: flipping a byte in \
prev_block_hash did not change the block hash (prev_block_hash is absent from \
the hash preimage chain continuity is not committed to)"
);
}
// We intentionally do NOT assert that the stored header hash equals the // ── INVARIANT 2c: timestamp is included in the hash preimage ─────────────────
// recomputed one: adversarially-crafted fuzz inputs can store an arbitrary {
// hash field that does not match the body content, and that is a valid input let mut m = base.clone();
// for the purpose of this target (which only tests hash stability, not m.timestamp = m.timestamp.wrapping_add(1);
// block validity). let hash_m = m.into_pending_block(&signing_key, bedrock).header.hash;
let _ = block.header.hash; assert_ne!(
hash_base,
hash_m,
"INVARIANT VIOLATION [HashPreimage/timestamp]: incrementing timestamp did not \
change the block hash (timestamp is absent from the hash preimage the block \
time can be rewritten without invalidating the hash)"
);
}
// ── INVARIANT 3: transaction-order commitment ─────────────────────────────────
//
// A hash commutative over its transaction list (e.g., XOR of per-tx hashes) would
// produce the same block hash for [tx_A, tx_B] and [tx_B, tx_A], enabling a silent
// transaction-reordering attack. We assert only when the first and last transactions
// are bytewise distinct — if they are identical, reversing the list is a semantic
// no-op and the matching hashes are correct.
if base.transactions.len() >= 2 {
let first = borsh::to_vec(&base.transactions[0])
.expect("serialising a fuzz-generated transaction must succeed");
let last = borsh::to_vec(&base.transactions[base.transactions.len() - 1])
.expect("serialising a fuzz-generated transaction must succeed");
if first != last {
let mut reordered = base.clone();
reordered.transactions.reverse();
let hash_reordered = reordered.into_pending_block(&signing_key, bedrock).header.hash;
assert_ne!(
hash_base,
hash_reordered,
"INVARIANT VIOLATION [TxOrderCommitment]: reversing the transaction list \
produced the same block hash the hash is commutative over transactions \
(a transaction-reordering attack is possible without invalidating the block hash)"
);
}
}
}); });