---
id: fuzz-encrypted-blob
name: Implement the encrypted_blob fuzz target
status: completed
depends_on: []
scope: moderate
risk: medium
impact: component
level: implementation
---
## Description
Implement the second fuzz target per the spec in
`docs/plans/fuzzing.md` (Targets table, `encrypted_blob` row; checklist
item 8). The surface is the frozen wire format (ADR-018): serde
deserialization of arbitrary bytes into `EncryptedData`, followed by
`VaultServiceHandle::decrypt` and `rotate` on a service unlocked with a
fixed known mnemonic. Arbitrary JSON arriving from storage must fail
clean, never panic.
The fuzz workspace already exists and `path_parse` is the reference
implementation for the family pattern. Follow it exactly:
- Invariant logic in `fuzz/shared/src/encrypted_blob.rs` as a plain
library module (stable-clean), exported from
`fuzz/shared/src/lib.rs`.
- Thin `fuzz_target!` wrapper in `fuzz/fuzz_targets/encrypted_blob.rs`,
`[[bin]]` entry in `fuzz/Cargo.toml`.
- Committed hand-made seeds in `fuzz/corpus/encrypted_blob/`, generated
by extending `fuzz/gen_fuzz_seeds.py` (deterministic, fixed inputs
only: hard-code a fixed sample wire-format JSON captured from a real
`encrypt` output, do not invoke the crate's RNG at seed-generation
time).
- Stable replay test: `fuzz/shared/src/encrypted_blob.rs` gets a
`#[cfg(test)]` corpus-replay module walking
`fuzz/corpus/encrypted_blob/` through the invariants (the quinn CI
pattern, same shape as `path_parse.rs`).
- Update `fuzz/README.md` Targets table with the row for this target.
Input design (two surfaces, mirroring raw + structured):
1. Raw bytes path: attempt `std::str::from_utf8`, then
`serde_json::from_slice::<EncryptedData>`; non-UTF-8 and
non-deserializing inputs are the clean-failure path.
2. Structured path: build a `serde_json::Value` field dictionary from
the corpus bytes (for example, byte-prefix selects `key_version`,
remaining bytes seed base64-ish strings for `salt`/`iv`/`data`),
deserialize it, and drive the same lifecycle. This reaches
field-level mutations libFuzzer hits slowly otherwise.
Invariants (from the plan; assert every one):
- No panic on any bytes, on the raw path and the structured path, and
on `decrypt`/`rotate` of whatever deserializes.
- Failures only in the permitted shapes: `VaultServiceError::Encryption`
wrapping an `EncryptionError::Decoding`/`Decryption` message, or
`VaultServiceError::InvalidPath` (service maps the derivation-path
rejection for `key_version` values outside the legal range; the
overflow fix in `encryption_path_for_version` means all legal
versions derive). Assert this with `matches!`; nothing else is
allowed to escape.
- Malformed base64, wrong IV length, and auth failures are clean errors
(assert reachable from crafted seeds; regression-pinned in the module
tests, not left to chance).
- UTF-8 failure path: decrypt of arbitrary bytes that happen to
authenticate is out of scope, but the invariant "UTF-8 failure
zeroizes" is asserted indirectly (no panic, clean `Decryption`
error) as the plan specifies.
- Round-trip only for blobs the service produced: when the corpus
entry deserializes and `decrypt` succeeds (self-encrypted fixed
seeds), `decrypt` output re-encrypted by the service decrypts back to
the same plaintext. Never assert round-trip on service-external
blobs.
- The fixed known mnemonic unlocks deterministically; `rotate` to a
legal new version either fails only with the permitted shapes or
produces a blob whose `key_version` equals the target version.
Regression pins in the module's `known_*_invariants`-style test:
one valid self-encrypted blob (hard-coded literal, fixed mnemonic
"abandon ... about" style test phrase matching the repo's existing
tests) round-trips; malformed-base64 and wrong-IV-length variants
return the expected shapes; `key_version` 0/1 reject as `InvalidPath`
mapped through the service error.
Housekeeping as part of the task, not afterthoughts: extend
`gen_fuzz_seeds.py` without disturbing the `path_parse` seeds; append
session notes to `docs/plans/fuzzing.md` only if a finding emerges
(checkbox flip is the closeout task's job, but keep it accurate).
## Acceptance Criteria
- [x] `fuzz/fuzz_targets/encrypted_blob.rs`, `fuzz/Cargo.toml` `[[bin]]`
entry, and `fuzz/shared/src/encrypted_blob.rs` exist; the target
wrapper delegates all invariant logic to the shared module
- [x] `cargo test --manifest-path fuzz/shared/Cargo.toml` (stable,
nightly not required) passes, replaying the committed
`fuzz/corpus/encrypted_blob/` seeds through the invariants
- [x] `cargo fuzz build` (run from `fuzz/`) builds the target clean
- [x] Seed corpus is committed, deterministic, regenerated identically
by `python3 fuzz/gen_fuzz_seeds.py`, and covers: valid wire
JSON, malformed base64, wrong IV length, wrong auth/ciphertext
truncation, unknown/most key_version picks, non-UTF-8 bytes,
truncated and padded JSON, extra unknown fields, null/missing
fields
- [x] A crafted regression pin demonstrates each permitted failure
shape (Decoding, Decryption, InvalidPath-via-service) and the
fixed-mnemonic self-encrypted round-trip; the pin lives in the
stable replay tests, not only in the target
- [x] `cargo test --all-features`, `cargo test`, `cargo clippy
--all-features --all-targets` still pass from the repo root; if
the campaign (separate task) finds real bugs, fix them with
regression tests and note them in
`docs/plans/fuzzing.md` session notes before closeout
## References
- `docs/plans/fuzzing.md` (Targets table row `encrypted_blob`; Layout
and conventions section)
- `fuzz/README.md` (parent conventions)
- `src/encryption.rs` (`EncryptedData` wire format, ADR-018 freeze)
- `src/service.rs` (`VaultServiceHandle::decrypt`, `rotate`)
- `src/derivation.rs` (`encryption_path_for_version` rejection range)
- Reference implementation: `fuzz/shared/src/path_parse.rs`,
`fuzz/fuzz_targets/path_parse.rs`
## Notes
- The pinned corpus literals are captured from a real `encrypt` output
with the fixed fuzz mnemonic (the repo's "abandon ... about" test
phrase, empty passphrase) over the pinned plaintext
"alkvault-fuzz encrypted blob round-trip pin": a v2 blob (also the
round-trip pin) and a v3 blob. Seed generation invokes no RNG, so
`python3 fuzz/gen_fuzz_seeds.py` is byte-identical across runs.
- Because the service flattens errors to strings, the permitted-shape
assertion combines a variant match (`Encryption`/`InvalidPath`) with
message-prefix checks ("base64 decoding error:", "decryption error:")
so `Decoding` and `Decryption` stay distinguishable.
- The structured path derives `key_version` from the first four input
bytes, splits the remainder three ways into base64 field strings, then
splices in string/number fields from any JSON object among the corpus
bytes, so field-level mutations reach the lifecycle immediately.
- 57 committed seeds; the stable replay test walks all of them plus a
`known_blob_invariants` pin (self-encrypted round-trip for both pinned
blobs, rotate to v3, malformed base64/wrong IV length/empty IV/corrupt
and truncated ciphertext shapes, v0/v1/overflow `InvalidPath`, max
legal `key_version` deriving clean, locked-vault `VaultLocked`).
- Salt-tampering seed confirms the v2 salt stays non-load-bearing
(decryption still succeeds and round-trips).
- A 30 s detached smoke campaign (`FUZZ_RUNTIME_SECS=30
fuzz/run-detached.sh encrypted_blob`) ran clean: 3347 executions,
oom/timeout/crash 0/0/0.
## Summary
Implemented the `encrypted_blob` fuzz target per the fuzzing plan:
invariant logic in `fuzz/shared/src/encrypted_blob.rs` (raw-deserialize
path plus a structured field-dictionary path into `EncryptedData`,
lifecycle drive of `decrypt`/`encrypt`/`rotate` over a
fixed-mnemonic `VaultServiceHandle`, permitted-shape failure assertion,
and round-trip only for blobs the service produced), a thin
`fuzz_target!` wrapper + `[[bin]]` entry, 57 deterministic committed
seeds generated by extending `fuzz/gen_fuzz_seeds.py`, stable corpus
replay + regression pins in `#[cfg(test)]`, and the `fuzz/README.md`
targets-table row. No findings emerged, so `docs/plans/fuzzing.md`
session notes were left untouched (checkbox flip belongs to the
closeout task). Verification: `cargo test --all-features` (112 tests),
`cargo test`, root and fuzz-shared clippy clean, `cargo fuzz build`
clean, corpus regeneration byte-identical, detached smoke run
0/0/0.