alkvault 0.2.0

Local key vault: BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, AES-256-GCM encryption for securing provider keys, credentials, and identity material
Documentation
---
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.