Skip to content

Commit eb3ef81

Browse files
authored
feat(bip0032): add fuzz targets (#117)
* feat(bip0032): add fuzz targets * fix fmt
1 parent d218cd3 commit eb3ef81

9 files changed

Lines changed: 471 additions & 17 deletions

File tree

bip0032/AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- `src/backend/` holds backend-specific secp256k1 implementations (k256, secp256k1, libsecp256k1).
99
- Tests live in `src/tests.rs` (BIP-32 vectors and invalid key cases).
1010
- Benchmarks are a workspace member under `benchmarks/`, with bench targets in `benchmarks/*.rs` and `benchmarks/serialize/`.
11+
- Auxiliary tooling: `benchmarks/` for benches and `fuzz/` for fuzz targets.
1112

1213
## Build, Test, and Development Commands
1314

@@ -16,6 +17,8 @@
1617
- `cargo test` runs unit tests for the enabled backend.
1718
- `cargo test --features secp256k1` or `cargo test --features k256ecdsa` runs tests against specific backends.
1819
- `just bench keygen` or `just benches` runs benchmarks (uses `benchmarks/` as the working dir); equivalent: `cargo bench --bench keygen -- --quiet` from `benchmarks/`.
20+
- `just fuzz <target> [runs]` runs fuzzing with nightly (`cargo +nightly fuzz`).
21+
- `just fuzz-clean` removes fuzz artifacts (`fuzz/artifacts`, `fuzz/corpus`).
1922

2023
## Coding Style & Naming Conventions
2124

bip0032/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[workspace]
2-
members = ["benchmarks"]
2+
members = ["benchmarks", "fuzz"]
33
resolver = "3"
44

55
[workspace.package]

bip0032/fuzz/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
corpus
2+
artifacts

bip0032/fuzz/Cargo.toml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[package]
2+
name = "bip0032-fuzz"
3+
version = "0.0.0"
4+
authors = ["koushiro <koushiro.cqx@gmail.com>"]
5+
edition.workspace = true
6+
rust-version.workspace = true
7+
license.workspace = true
8+
publish = false
9+
10+
[package.metadata]
11+
cargo-fuzz = true
12+
13+
[dependencies]
14+
arbitrary = { version = "1", features = ["derive"] }
15+
bip0032 = { path = ".." }
16+
bs58 = "0.5"
17+
libfuzzer-sys = "0.4"
18+
19+
[[bin]]
20+
name = "derive_path"
21+
path = "fuzz_targets/derive_path.rs"
22+
test = false
23+
doc = false
24+
25+
[[bin]]
26+
name = "parse_path"
27+
path = "fuzz_targets/parse_path.rs"
28+
test = false
29+
doc = false
30+
31+
[[bin]]
32+
name = "parse_xkey"
33+
path = "fuzz_targets/parse_xkey.rs"
34+
test = false
35+
doc = false
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#![no_main]
2+
3+
use arbitrary::Arbitrary;
4+
use bip0032::{
5+
ChildNumber, DerivationPath, ExtendedKeyPayload, ExtendedPrivateKey, ExtendedPublicKey,
6+
KnownVersion, backend::K256Backend,
7+
};
8+
use libfuzzer_sys::fuzz_target;
9+
10+
#[derive(Debug, Arbitrary)]
11+
struct Input<'a> {
12+
seed: &'a [u8],
13+
path_bytes: &'a [u8],
14+
max_seed_len: u8,
15+
max_path_len: u8,
16+
}
17+
18+
fn bounded_slice(bytes: &[u8], max_len: usize) -> &[u8] {
19+
let len = bytes.len().min(max_len);
20+
&bytes[..len]
21+
}
22+
23+
fn build_path(bytes: &[u8], max_children: usize) -> (DerivationPath, bool) {
24+
let mut children = Vec::new();
25+
let mut has_hardened = false;
26+
27+
for chunk in bytes.chunks(5).take(max_children) {
28+
if chunk.len() < 5 {
29+
break;
30+
}
31+
let mut index_bytes = [0u8; 4];
32+
index_bytes.copy_from_slice(&chunk[..4]);
33+
let mut index = u32::from_le_bytes(index_bytes);
34+
index &= 0x7FFF_FFFF;
35+
let hardened = (chunk[4] & 1) == 1;
36+
37+
if let Ok(child) = ChildNumber::new(index, hardened) {
38+
has_hardened |= hardened;
39+
children.push(child);
40+
}
41+
}
42+
43+
(DerivationPath::from(children), has_hardened)
44+
}
45+
46+
fn roundtrip_xprv(key: &ExtendedPrivateKey<K256Backend>) -> String {
47+
let encoded = key.encode_with(KnownVersion::Xprv.version()).unwrap().to_string();
48+
let payload = encoded.parse::<ExtendedKeyPayload>().unwrap();
49+
let decoded = ExtendedPrivateKey::<K256Backend>::try_from(payload).unwrap();
50+
let encoded2 = decoded.encode_with(KnownVersion::Xprv.version()).unwrap().to_string();
51+
assert_eq!(encoded2, encoded);
52+
encoded
53+
}
54+
55+
fn roundtrip_xpub(key: &ExtendedPublicKey<K256Backend>) -> String {
56+
let encoded = key.encode_with(KnownVersion::Xpub.version()).unwrap().to_string();
57+
let payload = encoded.parse::<ExtendedKeyPayload>().unwrap();
58+
let decoded = ExtendedPublicKey::<K256Backend>::try_from(payload).unwrap();
59+
let encoded2 = decoded.encode_with(KnownVersion::Xpub.version()).unwrap().to_string();
60+
assert_eq!(encoded2, encoded);
61+
encoded
62+
}
63+
64+
fuzz_target!(|input: Input<'_>| {
65+
let seed_len = (input.max_seed_len as usize).min(64);
66+
let seed = bounded_slice(input.seed, seed_len);
67+
68+
let max_children = (input.max_path_len as usize).min(32);
69+
let (path, has_hardened) = build_path(input.path_bytes, max_children);
70+
71+
let master = match ExtendedPrivateKey::<K256Backend>::new(seed) {
72+
Ok(master) => master,
73+
Err(_) => return,
74+
};
75+
76+
let derived = match master.derive_path(&path) {
77+
Ok(derived) => derived,
78+
Err(_) => {
79+
if !has_hardened {
80+
let _ = master.public_key().derive_path(&path);
81+
}
82+
return;
83+
},
84+
};
85+
86+
let xprv = roundtrip_xprv(&derived);
87+
let xpub = roundtrip_xpub(&derived.public_key());
88+
89+
let path_str = path.to_string();
90+
let parsed_path = path_str.parse::<DerivationPath>().unwrap();
91+
assert_eq!(parsed_path.to_string(), path_str);
92+
93+
if !has_hardened {
94+
let derived_pub = master.public_key().derive_path(&path).unwrap();
95+
let xpub_from_pub = roundtrip_xpub(&derived_pub);
96+
assert_eq!(xpub_from_pub, xpub);
97+
} else {
98+
assert!(master.public_key().derive_path(&path).is_err());
99+
}
100+
101+
let xprv_payload = xprv.parse::<ExtendedKeyPayload>().unwrap();
102+
assert!(xprv_payload.version().is_private());
103+
let xpub_payload = xpub.parse::<ExtendedKeyPayload>().unwrap();
104+
assert!(xpub_payload.version().is_public());
105+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#![no_main]
2+
3+
use arbitrary::Arbitrary;
4+
use bip0032::{ChildNumber, DerivationPath};
5+
use libfuzzer_sys::fuzz_target;
6+
7+
#[derive(Debug, Arbitrary)]
8+
struct Input<'a> {
9+
path_bytes: &'a [u8],
10+
mutate_bytes: &'a [u8],
11+
max_children: u8,
12+
mutate: bool,
13+
mutate_ops: u8,
14+
}
15+
16+
fn build_path(bytes: &[u8], max_children: usize) -> DerivationPath {
17+
let mut children = Vec::new();
18+
19+
for chunk in bytes.chunks(5).take(max_children) {
20+
if chunk.len() < 5 {
21+
break;
22+
}
23+
let mut index_bytes = [0u8; 4];
24+
index_bytes.copy_from_slice(&chunk[..4]);
25+
let mut index = u32::from_le_bytes(index_bytes);
26+
index &= 0x7FFF_FFFF;
27+
let hardened = (chunk[4] & 1) == 1;
28+
29+
if let Ok(child) = ChildNumber::new(index, hardened) {
30+
children.push(child);
31+
}
32+
}
33+
34+
DerivationPath::from(children)
35+
}
36+
37+
fn mutate_path(s: String, bytes: &[u8], ops: usize) -> String {
38+
if bytes.is_empty() || ops == 0 {
39+
return s;
40+
}
41+
42+
let alphabet = b"m/0123456789'hH";
43+
let mut buf = s.into_bytes();
44+
let len = bytes.len();
45+
46+
for i in 0..ops {
47+
let b = bytes[i % len];
48+
let action = b % 3;
49+
let pos = if buf.is_empty() { 0 } else { (bytes[(i + 1) % len] as usize) % buf.len() };
50+
let ch = alphabet[(bytes[(i + 2) % len] as usize) % alphabet.len()];
51+
52+
match action {
53+
0 => {
54+
let insert_pos = if buf.is_empty() { 0 } else { pos.min(buf.len()) };
55+
buf.insert(insert_pos, ch);
56+
},
57+
1 => {
58+
if !buf.is_empty() {
59+
buf[pos] = ch;
60+
}
61+
},
62+
_ => {
63+
if !buf.is_empty() {
64+
buf.remove(pos);
65+
}
66+
},
67+
}
68+
}
69+
70+
String::from_utf8_lossy(&buf).into_owned()
71+
}
72+
73+
fn assert_child_bounds(path: &DerivationPath) {
74+
for child in path.children() {
75+
assert!(child.index() < (1u32 << 31));
76+
}
77+
}
78+
79+
fuzz_target!(|input: Input<'_>| {
80+
let max_children = (input.max_children as usize).min(32);
81+
let path = build_path(input.path_bytes, max_children);
82+
83+
let canonical = path.to_string();
84+
let parsed = canonical.parse::<DerivationPath>().unwrap();
85+
assert_eq!(parsed.to_string(), canonical);
86+
assert_child_bounds(&parsed);
87+
88+
for child in parsed.children() {
89+
let text = child.to_string();
90+
let parsed_child = text.parse::<ChildNumber>().unwrap();
91+
assert_eq!(parsed_child.index(), child.index());
92+
assert_eq!(parsed_child.is_hardened(), child.is_hardened());
93+
}
94+
95+
if input.mutate {
96+
let ops = (input.mutate_ops as usize).min(8);
97+
let mutated = mutate_path(canonical, input.mutate_bytes, ops);
98+
if let Ok(parsed_mut) = mutated.parse::<DerivationPath>() {
99+
assert_child_bounds(&parsed_mut);
100+
let normalized = parsed_mut.to_string();
101+
let parsed_again = normalized.parse::<DerivationPath>().unwrap();
102+
assert_eq!(parsed_again.to_string(), normalized);
103+
}
104+
}
105+
});

0 commit comments

Comments
 (0)