Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,10 @@ jobs:
- uses: extractions/setup-just@v3
- uses: actions-rust-lang/setup-rust-toolchain@v1
- run: just bench
# Light smoke test fuzz.
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: extractions/setup-just@v3
- uses: actions-rust-lang/setup-rust-toolchain@v1
- run: just fuzz
- run: just test fuzz
4 changes: 1 addition & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
Cargo.lock
/target
# IDEs
.vscode/
**/target
24 changes: 18 additions & 6 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
# The recipes make heavy use of `rustup`'s toolchain syntax (e.g. `cargo +nightly`). `rustup` is
# required on the system in order to intercept the `cargo` commands and to install and use the appropriate toolchain with components.

NIGHTLY_TOOLCHAIN := "nightly-2025-06-19"
NIGHTLY_TOOLCHAIN := "nightly-2025-07-10"
STABLE_TOOLCHAIN := "1.87.0"
FUZZ_VERSION := "0.12.0"

_default:
@just --list
Expand All @@ -29,7 +30,7 @@ _default:
# Adding --fix flag to apply suggestions with --allow-dirty.
cargo +{{NIGHTLY_TOOLCHAIN}} clippy --workspace --all-features --all-targets --fix --allow-dirty -- -D warnings

# Run a test suite: unit, features, msrv, constraints, or no-std.
# Run a test suite: unit, features, msrv, constraints, no-std, or fuzz.
@test suite="unit":
just _test-{{suite}}

Expand Down Expand Up @@ -71,14 +72,25 @@ _default:
cargo install cross@0.2.5
$HOME/.cargo/bin/cross build --package bip324 --target thumbv7m-none-eabi --no-default-features

# Type check the fuzz targets.
@_test-fuzz:
cargo install cargo-fuzz@{{FUZZ_VERSION}}
cd protocol && cargo +{{NIGHTLY_TOOLCHAIN}} fuzz check

# Run benchmarks.
bench:
cargo +{{NIGHTLY_TOOLCHAIN}} bench --package bip324 --bench cipher_session

# Run fuzz test: receive_key, receive_garbage, receive_version.
@fuzz target="receive_garbage" time="60":
cargo install cargo-fuzz@0.12.0
cd protocol && cargo +{{NIGHTLY_TOOLCHAIN}} fuzz run {{target}} -- -max_total_time={{time}}
# Run fuzz target: receive_key or receive_garbage.
@fuzz target seconds:
rustup component add --toolchain {{NIGHTLY_TOOLCHAIN}} llvm-tools-preview
cargo install cargo-fuzz@{{FUZZ_VERSION}}
# Generate new test cases and add to corpus. Bumping length for garbage.
cd protocol && cargo +{{NIGHTLY_TOOLCHAIN}} fuzz run {{target}} -- -max_len=5120 -max_total_time={{seconds}}
# Measure coverage of corpus against code.
cd protocol && cargo +{{NIGHTLY_TOOLCHAIN}} fuzz coverage {{target}}
# Generate HTML coverage report.
protocol/fuzz/coverage.sh {{NIGHTLY_TOOLCHAIN}} {{target}}

# Add a release tag and publish to the upstream remote. Need write privileges on the repository.
@tag crate version remote="upstream":
Expand Down
9 changes: 2 additions & 7 deletions protocol/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
bip324 = { path = ".." }
rand = "0.8"
secp256k1 = "0.29"

[[bin]]
name = "receive_garbage"
Expand All @@ -18,13 +20,6 @@ test = false
doc = false
bench = false

[[bin]]
name = "receive_version"
path = "fuzz_targets/receive_version.rs"
test = false
doc = false
bench = false

[[bin]]
name = "receive_key"
path = "fuzz_targets/receive_key.rs"
Expand Down
44 changes: 44 additions & 0 deletions protocol/fuzz/coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash

# Generate HTML coverage report
# Usage: ./coverage.sh <toolchain> <target>
# Example: ./coverage.sh nightly-2025-07-10 receive_key
#
# Grabbed from this blog: https://tweedegolf.nl/en/blog/154/what-is-my-fuzzer-doing
# Hopefully standardized soon: https://github.com/taiki-e/cargo-llvm-cov/pull/431

set -euo pipefail

if [ $# -ne 2 ]; then
echo "Usage: $0 <toolchain> <target>"
echo "Example: $0 nightly-2025-07-10 receive_key"
exit 1
fi

TOOLCHAIN="$1"
TARGET="$2"

# Change to protocol directory.
cd "$(dirname "$0")/.."

# Install rustfilt for demangling.
cargo install rustfilt@0.2.1
RUSTFILT="$HOME/.cargo/bin/rustfilt"

# Get toolchain info
SYSROOT=$(rustc +${TOOLCHAIN} --print sysroot)
HOST_TUPLE=$(rustc +${TOOLCHAIN} --print host-tuple)

BINARY="target/$HOST_TUPLE/coverage/$HOST_TUPLE/release/$TARGET"

echo "Generating HTML coverage report for $TARGET..."
"$SYSROOT/lib/rustlib/$HOST_TUPLE/bin/llvm-cov" show \
"$BINARY" \
-instr-profile=fuzz/coverage/"$TARGET"/coverage.profdata \
-Xdemangler="$RUSTFILT" \
--format=html \
-output-dir=fuzz/coverage/"$TARGET"/html \
-ignore-filename-regex="\.cargo|\.rustup|fuzz_target|/rustc/"

REPORT_PATH="$(pwd)/fuzz/coverage/$TARGET/html/index.html"
echo "$REPORT_PATH"
71 changes: 37 additions & 34 deletions protocol/fuzz/fuzz_targets/receive_garbage.rs
Original file line number Diff line number Diff line change
@@ -1,63 +1,66 @@
// SPDX-License-Identifier: CC0-1.0

//! Fuzz test for the receive_garbage function.
//!
//! This focused test fuzzes only the garbage terminator detection logic,
//! which is more effective than trying to fuzz the entire handshake.

#![no_main]
use bip324::{GarbageResult, Handshake, Initialized, Network, ReceivedKey, Role};
use bip324::{Handshake, Initialized, Network, ReceivedKey, Role};
use libfuzzer_sys::fuzz_target;
use rand::SeedableRng;

fuzz_target!(|data: &[u8]| {
// Cap input size to avoid wasting time on obviously invalid large inputs
// The protocol limit is 4095 garbage bytes + 16 terminator bytes = 4111 total
// Test up to ~5000 bytes to cover boundary cases
if data.len() > 5000 {
return;
}

// Use deterministic seeds for reproducible fuzzing
let seed = [42u8; 32];
let mut rng = rand::rngs::StdRng::from_seed(seed);
let secp = secp256k1::Secp256k1::signing_only();

// Set up a valid handshake in the SentVersion state
let initiator = Handshake::<Initialized>::new(Network::Bitcoin, Role::Initiator).unwrap();
let initiator =
Handshake::<Initialized>::new_with_rng(Network::Bitcoin, Role::Initiator, &mut rng, &secp)
.unwrap();
let mut initiator_key = vec![0u8; Handshake::<Initialized>::send_key_len(None)];
let initiator = initiator.send_key(None, &mut initiator_key).unwrap();

let responder = Handshake::<Initialized>::new(Network::Bitcoin, Role::Responder).unwrap();
let mut rng2 = rand::rngs::StdRng::from_seed([43u8; 32]);
let responder =
Handshake::<Initialized>::new_with_rng(Network::Bitcoin, Role::Responder, &mut rng2, &secp)
.unwrap();
let mut responder_key = vec![0u8; Handshake::<Initialized>::send_key_len(None)];
let responder = responder.send_key(None, &mut responder_key).unwrap();

// Exchange keys using real keys to get valid ECDH shared secrets
let initiator = initiator
.receive_key(responder_key[..64].try_into().unwrap())
.unwrap();
let _responder = responder
let responder = responder
.receive_key(initiator_key[..64].try_into().unwrap())
.unwrap();

// Get the real responder's garbage terminator from responder's send_version output
let mut responder_version = vec![0u8; Handshake::<ReceivedKey>::send_version_len(None)];
let _responder = responder
.send_version(&mut responder_version, None)
.unwrap();

// The responder's garbage terminator is in the first 16 bytes of their version output
let responder_terminator = &responder_version[..16];

// Send version to reach SentVersion state
let mut initiator_version = vec![0u8; Handshake::<ReceivedKey>::send_version_len(None)];
let initiator = initiator
.send_version(&mut initiator_version, None)
.unwrap();

// Now fuzz the receive_garbage function with arbitrary data
match initiator.receive_garbage(data) {
Ok(GarbageResult::FoundGarbage {
handshake: _,
consumed_bytes,
}) => {
// Successfully found garbage terminator
// Verify consumed_bytes is reasonable
assert!(consumed_bytes <= data.len());
assert!(consumed_bytes >= 16); // At least the terminator size

// The garbage should be everything before the terminator
let garbage_len = consumed_bytes - 16;
assert!(garbage_len <= 4095); // Max garbage size
}
Ok(GarbageResult::NeedMoreData(_)) => {
// Need more data - valid outcome for short inputs
// This should happen when:
// 1. Buffer is too short to contain terminator
// 2. Buffer doesn't contain the terminator yet
}
Err(_) => {
// Error parsing garbage - valid outcome
// This should happen when:
// 1. No terminator found within max garbage size
}
}
// Create realistic test case: fuzz_data + real_terminator
let mut realistic_input = data.to_vec();
realistic_input.extend_from_slice(responder_terminator);

// Test the receive_garbage function with realistic input
let _ = initiator.receive_garbage(&realistic_input);
});
40 changes: 25 additions & 15 deletions protocol/fuzz/fuzz_targets/receive_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,47 @@
#![no_main]
use bip324::{Handshake, Initialized, Network, Role};
use libfuzzer_sys::fuzz_target;
use rand::SeedableRng;

fuzz_target!(|data: &[u8]| {
// Skip if data is not exactly 64 bytes
// We need exactly 64 bytes for an ElligatorSwift key.
// This gives the fuzzer a clear signal about the expected input size.
if data.len() != 64 {
return;
}

// Set up a handshake in the SentKey state
let handshake = Handshake::<Initialized>::new(Network::Bitcoin, Role::Initiator).unwrap();
// Use a fixed seed to make the fuzzing deterministic.
// This ensures same input always produces same result.
let seed = [42u8; 32];
let mut rng = rand::rngs::StdRng::from_seed(seed);
let secp = secp256k1::Secp256k1::signing_only();

// Set up a handshake in the SentKey state.
let handshake =
Handshake::<Initialized>::new_with_rng(Network::Bitcoin, Role::Initiator, &mut rng, &secp)
.unwrap();
let mut key_buffer = vec![0u8; Handshake::<Initialized>::send_key_len(None)];
let handshake = handshake.send_key(None, &mut key_buffer).unwrap();

// Fuzz the receive_key function with arbitrary 64-byte data
// Convert the data to the required array format.
let mut key_bytes = [0u8; 64];
key_bytes.copy_from_slice(data);

match handshake.receive_key(key_bytes) {
Ok(_handshake) => {
// Successfully processed the key
// This means:
// 1. The 64 bytes represent a valid ElligatorSwift encoding
// 2. The ECDH operation succeeded
// 3. The key derivation worked
// 4. It's not the V1 protocol magic bytes
// Successfully processed the key.
//
// 1. The 64 bytes represent a valid ElligatorSwift encoding.
// 2. The ECDH operation succeeded.
// 3. The key derivation worked.
// 4. It's not the V1 protocol magic bytes.
}
Err(_) => {
// Failed to process the key
// This could be:
// 1. Invalid ElligatorSwift encoding
// 2. V1 protocol detected (first 4 bytes match network magic)
// 3. ECDH or key derivation failure
// Failed to process the key.
//
// 1. Invalid ElligatorSwift encoding.
// 2. V1 protocol detected (first 4 bytes match network magic).
// 3. ECDH or key derivation failure.
}
}
});
70 changes: 0 additions & 70 deletions protocol/fuzz/fuzz_targets/receive_version.rs

This file was deleted.

Loading