---
id: fuzz-derived-key-json
name: Implement the derived_key_json fuzz target
status: completed
depends_on: [fuzz-encrypted-blob]
scope: narrow
risk: low
impact: component
level: implementation
---
## Description
Implement the third fuzz target per the spec in `docs/plans/fuzzing.md`
(Targets table, `derived_key_json` row; checklist item 9). The surface
is `DerivedKey`'s custom serde round: `serde_json::from_slice::<
DerivedKey>` over arbitrary JSON, plus a structured `serde_json::Value`
path built from the corpus bytes. The custom `Deserialize`
(`src/protocol.rs`) carries the redacted-payload guard, and the
invariants pin that it can never be bypassed and never leaks key
bytes.
Depends on `fuzz-encrypted-blob` only to serialize the shared-file
edits (`gen_fuzz_seeds.py`, `fuzz/shared/src/lib.rs`, `fuzz/Cargo.toml`,
`fuzz/README.md`); there is no semantic coupling. Follow the
`path_parse` reference pattern exactly (shared module, thin
`fuzz_target!` wrapper, committed seeds, stable replay test, README
Targets row).
Input design:
1. Raw bytes path: `serde_json::from_slice::<DerivedKey>(data)`
directly.
2. Structured path: parse the bytes as a `serde_json::Value` (or build
a JSON object whose fields are chosen from byte prefixes), then
deserialize into `DerivedKey`; this reaches field permutations
(wrong types, missing fields, huge arrays) systematically.
Invariants (from the plan; assert every one):
- No panic on any bytes, both paths. Deserialization failures are
always serde errors, never aborts (guard the `Zeroize`-on-drop
helper path: a `DerivedKey` constructed from deserialization must
drop cleanly even when its `private_key` is garbage).
- Redacted payloads are rejected with an error message that mentions
the `[REDACTED]` marker and leaks no key bytes. Both forms are
in scope per the plan checklist: the string form
`"private_key":"[REDACTED]"` (rejected at type coercion: a JSON
string cannot become `Vec<u8>`) and the byte-array form
(`[82,69,68,...]` spelling `[REDACTED]`), which is the form that
actually reaches the guard at `src/protocol.rs:78`. Seeds must
include both.
- Any successful deserialization re-serializes with
`"private_key":"[REDACTED]"` and the serialized output never
contains the real private key bytes that went in.
- `Debug` output of a deserialized `DerivedKey` redacts
`private_key` (extend the leak scan to any key bytes present in
the corpus entry and any key bytes produced through the lifecycle).
- Failure messages never echo `private_key` values.
Housekeeping: extend `gen_fuzz_seeds.py` (preserve the `path_parse`
and `encrypted_blob` seed functions); append the README Targets row;
keep `docs/plans/fuzzing.md` session notes accurate if a finding
emerges.
## Acceptance Criteria
- [x] `fuzz/fuzz_targets/derived_key_json.rs`, `fuzz/Cargo.toml`
`[[bin]]` entry, and `fuzz/shared/src/derived_key_json.rs` exist;
the wrapper delegates to the shared module
- [x] `cargo test --manifest-path fuzz/shared/Cargo.toml` (stable)
passes, replaying `fuzz/corpus/derived_key_json/` through the
invariants
- [x] `cargo fuzz build` (from `fuzz/`) builds the target clean
- [x] Seed corpus is deterministic, regenerated identically by
`python3 fuzz/gen_fuzz_seeds.py`, and includes both redacted
forms (string and byte-array), a valid non-redacted payload with
32-byte keys, all three `KeyType` variants, missing/extra/null
fields, wrong field types, oversized and empty key arrays, and
non-JSON bytes
- [x] Stable regression pins: each redacted form is rejected with a
marker-mentioning, non-leaking message; a valid payload
re-serializes with `[REDACTED]` and without the private bytes
- [x] `cargo test --all-features`, `cargo test`, `cargo clippy
--all-features --all-targets` still pass from the repo root
## References
- `docs/plans/fuzzing.md` (Targets table row `derived_key_json`)
- `src/protocol.rs` (custom `Deserialize`, redacted-payload guard at
the `b"[REDACTED]"` check, redacting `Debug` and `Serialize`)
- `fuzz/README.md`
- Reference implementation: `fuzz/shared/src/path_parse.rs`
## Notes
- The structured path builds a synthetic JSON object whose `key_type`
is chosen from the first input byte (0 Ed25519, 1 Aes256Gcm, 2
Secp256k1, otherwise the unknown variant "NotARealKeyType") and whose
`private_key`/`public_key` are the remaining bytes split in half.
When the corpus bytes themselves parse as a JSON object, its fields
are spliced in (present fields override, absent fields are removed),
so the structured path reaches missing fields, null fields, extra
fields, and wrong types systematically instead of only via the raw
path.
- The redaction checks are encoding-specific rather than raw substring
scans: the serialized JSON is re-parsed and `private_key` must equal
exactly `"[REDACTED]"`, the wire may only carry key_type, the
redacted private_key, and public_key, and the private bytes must not
appear as a decimal array (compact form in JSON, spaced Debug form in
`Debug`). The decimal check is skipped only when the payload echoed
the private bytes as public_key, which the wire legitimately carries.
- No substring scanning of private bytes in outputs: fixed labels
(`"private_key"`, `DerivedKey`) in serialized/Debug/error text are
matchable by attacker-chosen payload bytes, so a generic scan would
false-positive. The known-pins test keeps the protocol.rs-style hex
checks (`"AB"`) with fixed content where they are sound.
- The byte-array guard assertion is preconditioned: it fires only when
key_type is a valid variant and public_key coerces to `Vec<u8>`, so a
failed coercion elsewhere cannot be mistaken for the guard firing.
- The re-serialization invariant pins that the redacted serialized form
never deserializes back, asserting the documented "round-tripping is
not supported" behavior from the other side.
- The string-form redacted rejection message mentions `[REDACTED]`
because serde echoes the offending JSON string in type-coercion
errors; pinned as observed.
- 43 committed seeds covering both redacted forms, three valid
per-`KeyType` payloads with 32-byte keys, whitespace padding, missing
(each field and empty object) and null and extra fields, wrong types
for each field, out-of-range byte values, oversized (4096-byte)/
empty/short key arrays, non-JSON and degenerate bytes, and
structured-path prefix fixtures; the stable replay test walks all of
them plus a `known_key_pins` regression test.
- Housekeeping: fixed a pre-existing `manual_range_contains` clippy
warning in `fuzz/shared/src/path_parse.rs` that had accumulated from
a newer clippy (the fuzz workspace is outside the root verification,
but the shared crate should stay warning-free).
- A 30 s detached smoke campaign (`FUZZ_RUNTIME_SECS=30
fuzz/run-detached.sh derived_key_json`) ran clean: ~127k executions,
oom/timeout/crash 0/0/0.
## Summary
Implemented the `derived_key_json` fuzz target per the fuzzing plan:
invariant logic in `fuzz/shared/src/derived_key_json.rs` (raw-bytes
`serde_json::from_slice::<DerivedKey>` plus a structured `Value` path
with prefix-derived defaults and field splice/removal from embedded
JSON, redaction-encoding assertions on serialize and `Debug`, the
preconditioned byte-array guard assertion, re-deserialize-must-fail,
and drop-clean on garbage keys), a thin `fuzz_target!` wrapper +
`[[bin]]` entry, 43 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 --manifest-path fuzz/shared/Cargo.toml`
(6 tests), fuzz-shared clippy clean, `cargo fuzz build` clean, corpus
regeneration byte-identical, `cargo test --all-features` (112 tests),
`cargo test` (105 tests), root clippy clean, detached smoke run 0/0/0.