Skip to content

Commit 48e6020

Browse files
UnbreakableMJclaude
andcommitted
feat(fuzz): EncString parser fuzz harness (PRD §11.4)
Adds a cargo-fuzz / libFuzzer harness for the security-critical Bitwarden "type 2" EncString parser — the §11.4 / M7 groundwork toward the v0.1 tag. The target feeds arbitrary input to `EncString::parse` and, on success, asserts the parse → serialize → parse round-trip is stable (catches panics and parser/serializer disagreement). - fuzz/: a standalone workspace (own empty [workspace]); root Cargo.toml also `exclude`s it. cargo-fuzz needs nightly + a sanitizer runtime, so isolating it keeps the CI gates (fmt/clippy/test/deny/headless) and the workspace `forbid(unsafe_code)` untouched — the target's `#![no_main]` + libFuzzer's internal unsafe live only here. - docs/fuzzing.md: prerequisites, run command, the ≥24h soak, corpus seeding. - README pointer + CHANGELOG entry. Verified: `cargo +nightly fuzz build` compiles; a 30s smoke run did ~2.1M iterations with no finding. The full ≥24h soak is a maintainer step. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 07fca3c commit 48e6020

7 files changed

Lines changed: 140 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ range may break in any release.
1010

1111
### Added
1212

13+
- **`EncString` parser fuzz harness.** A cargo-fuzz / libFuzzer target
14+
(`fuzz/fuzz_targets/enc_string_parse.rs`) for the security-critical Bitwarden
15+
"type 2" parser — feeds arbitrary input to `EncString::parse` and asserts the
16+
parse → serialize → parse round-trip is stable. Lives in a standalone `fuzz/`
17+
workspace (nightly + sanitizer only), so the CI gates are untouched; run with
18+
`cargo +nightly fuzz run enc_string_parse` (see `docs/fuzzing.md`). Groundwork
19+
toward the PRD §11.4 / M7 `v0.1` gate (the ≥ 24 h soak is a maintainer step).
20+
1321
- **TUI identity create/edit (curated).** The `vault-tui` add/edit form (`a`/`e`)
1422
now composes identity (type 4) items — the **Type** row cycles `login → secure
1523
note → card → identity`. To fit the non-scrolling overlay it edits a curated

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ members = [
1313
"crates/vault-theme",
1414
"crates/vault-config",
1515
]
16+
# The cargo-fuzz harness is a standalone workspace (nightly + sanitizer only);
17+
# keep it out of `cargo <cmd> --workspace` and the CI gates.
18+
exclude = ["fuzz"]
1619

1720
[workspace.package]
1821
version = "0.0.1"

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ sibling of the `vault` binary, then `$PATH` (override with
4747
`$VAULT_AGENT_BIN`; opt out per-call with `--no-auto-spawn`). A spawned
4848
agent logs to `agent.log` beside the socket.
4949

50+
The security-critical `EncString` parser has a cargo-fuzz harness under `fuzz/`
51+
(a standalone workspace, run on nightly) — see [`docs/fuzzing.md`](docs/fuzzing.md).
52+
5053
## Getting started
5154

5255
```sh

docs/fuzzing.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
2+
3+
# Fuzzing the `EncString` parser
4+
5+
Vault fuzzes its security-critical Bitwarden "type 2" `EncString` parser
6+
(`crates/vault-core/src/enc_string.rs`) with [`cargo-fuzz`] / libFuzzer. The
7+
parser base64-decodes attacker-influenceable cache and `/sync` strings into
8+
IV / ciphertext / MAC, so it must never panic and must round-trip cleanly. PRD
9+
§11.4 requires this harness to run **≥ 24 h with no findings** as part of the
10+
`v0.1` tag gate (§12 M7).
11+
12+
## Layout
13+
14+
The harness is a **standalone workspace** under `fuzz/`, deliberately kept out of
15+
the main workspace (the root `Cargo.toml` lists explicit `members` and also
16+
`exclude = ["fuzz"]`, and `fuzz/Cargo.toml` carries its own empty `[workspace]`).
17+
That isolation matters: cargo-fuzz needs a nightly toolchain and a sanitizer
18+
runtime, so the repo's CI gates (`fmt` / `clippy -D warnings` / `test` / `deny` /
19+
headless builds) never try to build it. The fuzz target is also the one place
20+
`#![no_main]` and libFuzzer's internal `unsafe` live — outside the workspace's
21+
`#![forbid(unsafe_code)]`.
22+
23+
- `fuzz/Cargo.toml` — the `vault-fuzz` package.
24+
- `fuzz/fuzz_targets/enc_string_parse.rs` — the target: parse arbitrary input,
25+
and on success assert the parse → serialize → parse round-trip is stable.
26+
27+
## Prerequisites
28+
29+
```sh
30+
rustup toolchain install nightly
31+
cargo install cargo-fuzz # provides `cargo fuzz` (libFuzzer driver)
32+
```
33+
34+
## Run
35+
36+
```sh
37+
# from the repo root
38+
cargo +nightly fuzz run enc_string_parse
39+
```
40+
41+
Seed the corpus with a real example so libFuzzer starts from valid structure
42+
(optional but speeds up coverage):
43+
44+
```sh
45+
mkdir -p fuzz/corpus/enc_string_parse
46+
printf '2.%s|%s|%s' \
47+
"$(head -c16 /dev/zero | base64)" \
48+
"$(head -c16 /dev/zero | base64)" \
49+
"$(head -c32 /dev/zero | base64)" \
50+
> fuzz/corpus/enc_string_parse/seed
51+
```
52+
53+
### The §11.4 soak
54+
55+
```sh
56+
# 24 hours (86400 s); findings are written under fuzz/artifacts/
57+
cargo +nightly fuzz run enc_string_parse -- -max_total_time=86400
58+
```
59+
60+
A crash or assertion failure drops a reproducer in
61+
`fuzz/artifacts/enc_string_parse/`; replay it with
62+
`cargo +nightly fuzz run enc_string_parse fuzz/artifacts/enc_string_parse/<file>`.
63+
The `corpus/`, `artifacts/`, `target/`, and `coverage/` directories are
64+
git-ignored.

fuzz/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
target/
3+
corpus/
4+
artifacts/
5+
coverage/
6+
Cargo.lock

fuzz/Cargo.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
3+
[package]
4+
name = "vault-fuzz"
5+
version = "0.0.0"
6+
publish = false
7+
edition = "2021"
8+
license = "GPL-3.0-or-later"
9+
10+
# Standalone workspace: cargo-fuzz needs nightly + a sanitizer runtime, so this
11+
# crate is deliberately kept out of the main workspace (whose `members` list is
12+
# explicit and whose root `Cargo.toml` also `exclude`s `fuzz`). That way the
13+
# repo's CI gates — fmt / clippy -D warnings / test / deny / headless builds —
14+
# never try to build the nightly-only fuzz target.
15+
[workspace]
16+
17+
[package.metadata]
18+
cargo-fuzz = true
19+
20+
[dependencies]
21+
libfuzzer-sys = "0.4"
22+
vault-core = { path = "../crates/vault-core" }
23+
24+
[[bin]]
25+
name = "enc_string_parse"
26+
path = "fuzz_targets/enc_string_parse.rs"
27+
test = false
28+
doc = false
29+
bench = false
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
3+
//! Fuzz the security-critical Bitwarden "type 2" `EncString` parser.
4+
//!
5+
//! `EncString::parse` base64-decodes attacker-influenceable cache/sync strings
6+
//! into IV / ciphertext / MAC, so it must never panic and must be internally
7+
//! consistent. This target feeds arbitrary bytes (as lossy UTF-8) to the parser
8+
//! and, on a successful parse, asserts the parse → serialize → parse round-trip
9+
//! is stable — catching both panics and any parser/serializer disagreement.
10+
//!
11+
//! Run with `cargo +nightly fuzz run enc_string_parse` (see `docs/fuzzing.md`).
12+
13+
#![no_main]
14+
15+
use libfuzzer_sys::fuzz_target;
16+
use vault_core::EncString;
17+
18+
fuzz_target!(|data: &[u8]| {
19+
let s = String::from_utf8_lossy(data);
20+
if let Ok(parsed) = EncString::parse(&s) {
21+
// The canonical serialization of a parsed value must itself parse back
22+
// to an equal value — the parser and serializer must agree.
23+
let reparsed =
24+
EncString::parse(&parsed.serialize()).expect("serialize() output must re-parse");
25+
assert_eq!(parsed, reparsed, "parse/serialize round-trip diverged");
26+
}
27+
});

0 commit comments

Comments
 (0)