Files
alkvault/docs/plans/fuzzing.md
T
glm-5.3-flash 8fcdcefac3 Close out the fuzzing plan: CHANGELOG note, checklist, session notes
- Extend the CHANGELOG 0.2.0 fuzzing entry to the delivered state:
  all four targets, shared-invariants stable replay, the excluded
  nightly-only fuzz workspace, the four clean campaigns, and the
  findings summary (three fixes from the scope review, none new)
- Check the plan's final checklist item and move the Status line
  to done
- Append the dated session-5 closeout entry to the plan notes
- Mark the fuzz-changelog-closeout task completed with notes and
  summary

Verification: 114/107 tests (all-features/default), clippy clean,
docs clean, shared replay 8/8, publish dry-run clean.
2026-11-04 08:45:50 +00:00

14 KiB

alkvault fuzzing plan

Status: done (closed out 2026-11-04). alkvault was the first crate written in the alk family and predates the family's fuzzing convention. Every later crate (alkcall, alksocks, alktty, alktunnels, and most others) fuzzes its untrusted-input surfaces. This plan brings alkvault in line, case by case, per the family rule: everything that makes sense to fuzz gets fuzzed, and the decision is explicit where the answer is no. If this plan spans multiple sessions, append session notes at the bottom and keep task checkboxes current.

Why now / first findings

The scoping pass itself found two panics before any campaign ran, which validates the exercise:

  1. parse_derivation_path (src/derivation.rs): the hardened-index encoding index + 0x80000000 overflowed u32 for any hardened index > 2147483647 (m/4294967295'): panic in debug, silent wrap into a meaningless index in release. Fixed (returns DerivationError::InvalidPath); regression tests added.
  2. EncryptionKey::from_derived_bytes (src/encryption.rs): indexed &bytes[..32] without a length check and panicked on material shorter than 32 bytes. Fixed (returns EncryptionError::InvalidLength); the signature change to Result was approved for the unpublished 0.2.0; regression tests added.
  3. encryption_path_for_version (src/derivation.rs, found while writing the path_parse invariants): versions above 0x80000001 produced path strings the parser rejects after the overflow fix (a version-derived path failing at parse time). Fixed (returns DerivationError::InvalidPath directly for versions that overflow the hardened encoding); all in-range versions produce identical paths.

Scope decisions (case by case)

Surface Decision Rationale
parse_derivation_path + derive_path_from_seed + path helpers Fuzz, first Pure text parser on caller-provided strings; confirmed panic bug. Covers encryption_path_for_version, device_path, and the PATHS regression pins in the same target
EncryptedData deserialize + decrypt/rotate lifecycle Fuzz The frozen wire format (ADR-018) arrives from storage and is shared with the TypeScript consumer; arbitrary JSON must fail clean, never panic
DerivedKey serde (custom Deserialize with redacted-payload guard) Fuzz, cheap Protocol JSON is consumed across crate boundaries; the redaction guard and no-panic contract are cheap to assert
Mnemonic::from_phrase / VaultServiceHandle::unlock Fuzz, cheap The phrase is typed by the user at CLI restore; the bip39 crate does the parsing but the wrapper contract (no panic, Debug redaction) is cheap to pin
KeyCache op sequences (insert/get/TTL evict/LRU/clear) Deferred, no target Internal state logic, no untrusted bytes. Decided 2026-11-04 after code review: the order/entries key-set consistency and LRU ordering hold structurally by construction (every entries mutation pairs with an order mutation via remove_entry/insert/clear), and the deterministic suite already covers LRU reorder, TTL eviction, replace, and clear. The one real finding (max_entries = 0 grew past the bound) is fixed with a regression test rather than fuzzed: a no-op insert guard, since no comparison toggle fixes that case. The alktty session_opseq precedent would be the shape to revisit if the cache grows non-trivial logic
service.rs full lifecycle opseq Skip Round-trip/rotate/version-mapping invariants are already covered by the deterministic test suite; overlapping value is low
Crypto cores (AES-GCM, bip39, SLIP-0010/ed25519-bip32, HMAC) Skip Third-party, hardened crates; we fuzz through the vault's wrappers, not the primitives

Layout and conventions (inherited)

The tooling posture is inherited from alkcall's docs/research/fuzzing.md as relayed by alktunnels' fuzz/README.md; alkvault has no separate research doc, that document remains the family reference.

One target per fuzz_targets/ binary, thin fuzz_target! wrappers. All invariant logic lives in the shared/ crate as a plain library so cargo test --manifest-path fuzz/shared/Cargo.toml (stable toolchain) replays the committed corpora through the same invariants (the quinn CI pattern); fuzz binaries alone are nightly-only. Only hand-made seed-* corpora are committed (regenerate with python3 fuzz/gen_fuzz_seeds.py); grown corpus entries and artifacts//coverage/ are gitignored. fuzz/run-detached.sh is the mandatory runner for agent sessions (setsid + nohup + log redirection, so an OOM cannot take down the host). The fuzz workspace is excluded from the root workspace and from the published package (exclude in the root Cargo.toml); it pins nightly via fuzz/rust-toolchain.toml and does not touch the crate's stable MSRV.

Targets

Target Drives Invariants
path_parse (done) parse_derivation_path, derive_path_from_seed (fixed 64-byte seed), encryption_path_for_version (arbitrary u32), device_path (arbitrary u32) no-panic on any bytes; only InvalidPath errors ever; deterministic (re-parse and re-derive agree); hardened-index overflow rejected (regression: m/2147483648', m/4294967295'); PATHS constants parse to pinned index tables; encryption_path_for_version v0/v1 rejected, v>=2 maps to a parseable path whose last index is (version - 2) + 2^31; derived keys are 32+32 bytes with matching path echo
encrypted_blob serde into EncryptedData from arbitrary bytes, then VaultServiceHandle::decrypt/rotate on a fixed known mnemonic no-panic on raw bytes and structured JSON; failure types only (Decoding, Decryption, InvalidPath mapped through the service error); malformed base64/IV length/auth failure all clean; UTF-8 failure path zeroizes (asserted indirectly: decrypt of arbitrary bytes never panics); round-trip only for blobs the service itself encrypted
derived_key_json serde_json::from_slice::<DerivedKey> over arbitrary JSON (raw bytes path plus structured Value path) no-panic; redacted payloads (private_key == b"[REDACTED]", both string and byte-array forms) are rejected with a message mentioning the marker and leaking no key bytes; successful deserializes re-serialize with [REDACTED] private key and never emit real private bytes on the wire
mnemonic_unlock Mnemonic::from_phrase + VaultServiceHandle::unlock/lock/derive/encrypt sequence over arbitrary bytes as phrase no-panic; failure is only MnemonicError::InvalidPhrase shaped; Debug of the mnemonic redacts the phrase (scan arbitrary/seed words against the redacted output); derived keys on valid mnemonics are deterministic per path

Deferred: cache_opseq was decided against (see Scope decisions); the max_entries = 0 bound finding was fixed directly with a regression test instead of fuzzed.

Task checklist

The remaining checklist items are decomposed into task files under tasks/ (prefix fuzz_), with declared dependencies and acceptance criteria; execute in dependency order.

  • Scope pass: surface inventory, decisions table, discussion (2026-11-04)
  • Fix parse_derivation_path hardened-index overflow + regression tests
  • Fix EncryptionKey::from_derived_bytes short-input panic (Result) + regression tests
  • Fix encryption_path_for_version hardened-encoding overflow for huge versions + regression tests
  • CHANGELOG 0.2.0 entries for both fixes
  • Fuzz workspace scaffold (Cargo.toml, rust-toolchain.toml, run-detached.sh, gen_fuzz_seeds.py, .gitignore, README, root Cargo.toml excludes)
  • path_parse target: shared invariants, fuzz target, seed corpus, stable replay test
  • encrypted_blob target: shared invariants, fuzz target, seed corpus, stable replay test
  • derived_key_json target: shared invariants, fuzz target, seed corpus (include the string and byte-array redacted forms), stable replay test
  • mnemonic_unlock target: shared invariants, fuzz target, seed corpus (valid 12/24-word phrases, near-miss edits), stable replay test
  • Detached campaigns for each target (FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh <target>); review fuzz/artifacts/ logs, no crashes/ooms/timeouts
  • Decide on cache_opseq: deferred with rationale (Scope decisions table); the max_entries = 0 bound bug fixed in src/cache.rs with regression tests
  • CHANGELOG 0.2.0 note for the fuzzing introduction (delivered state: all four targets, stable replay, workspace exclusions; campaign findings were nil beyond the fixes above)

Verification

cargo test --all-features
cargo test                                     # default features
cargo clippy --all-features --all-targets
cargo test --manifest-path fuzz/shared/Cargo.toml   # stable corpus replay

cd fuzz/                                       # nightly, pinned toolchain
cargo fuzz build
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh path_parse
cargo fuzz coverage path_parse                 # when useful

Session notes

  • 2026-11-04 (session 1): scoping, the three overflow fixes, workspace scaffold, and the path_parse target complete. Verification: 112/105 tests (all-features/default) pass, clippy clean, shared replay tests pass, cargo fuzz build clean, cargo publish --dry-run --all-features --allow-dirty clean. First detached campaign (path_parse, 193 s budget): ~440k executions, oom/timeout/crash 0/0/0, coverage 718 blocks. Next: the encrypted_blob target is the natural starting point for a follow-up session (wire-format surface, highest residual value).

  • 2026-11-04 (session 3): the three detached campaigns ran against the previously smoke-tested targets, all to their full 600 s budget (-max_total_time=600, fork mode, exiting: 0), none stopped early, and across all three logs crash/oom/timeout is 0/0/0, so zero regression tests or fixes were needed:

    Target Execs cov blocks ft corp oom/timeout/crash
    encrypted_blob 73,473 3,118 8,187 649 0/0/0
    derived_key_json 2,090,033 2,175 7,668 1,536 0/0/0
    mnemonic_unlock 7,950,541 1,934 2,984 299 0/0/0

    The cov figures are libFuzzer's end-of-campaign counter, the same measure as the path_parse baseline's 718 (from its 193 s campaign); encrypted_blob is by far the slowest (full AEAD decrypt work per exec) while the other two run millions of execs, and no surface plateaued in a way that suggests unreachable code. Each count is in the log at fuzz/artifacts/<target>-20261104-080728.log. Zero findings matches the task's expectation for derived_key_json and mnemonic_unlock (thin wrappers) and is a clean bill for encrypted_blob too. Findings closeout routed here from the review task: finding 1 (assert messages echo synthetic inputs) is accepted as fuzz evidence with an acceptance note in the affected modules (fuzz/shared/src/mnemonic_unlock.rs, fuzz/shared/src/derived_key_json.rs); finding 2 (repeated-word pin wording) is fixed by a fuzz/README.md reword. The closeout edits touch no library surface. Verification: 112/105 tests (all-features/default) pass, clippy clean, shared replay 8/8 pass. Grown corpora and the campaign logs stay gitignored; committed corpora remain seed-only.

  • 2026-11-04 (session 4): cache_opseq decided and checklist item 12 closed. Code review of src/cache.rs confirmed the max_entries = 0 claim: insert took the entries.len() >= max_entries branch (always true at 0), but evict_lru found an empty order and evicted nothing, so the cache permanently held one entry against a zero-entry bound. No comparison toggle fixes that case (a > swap would admit growth outright), so the fix is an explicit zero-guard: insert returns early when max_entries == 0 (cache disabled, key dropped and zeroized). Regression tests: the deterministic zero-bound test that failed before the fix, plus a max_entries = 1 edge pin. The go/defer decision is Deferred with no target: the remaining candidate invariants (order/entries key-set consistency, LRU ordering) hold structurally by construction since every entries mutation pairs with an order mutation, and the deterministic suite already pins the LRU reorder, TTL eviction, replace, and clear interleavings, so a reference-model fuzzer (alktty session_opseq shape) would add little on this surface; revisit if the cache accumulates non-trivial logic. No separate ticket filed: the fix is completed, which per the task's acceptance criteria satisfies the ticket requirement. Verification: 114/107 tests (all-features/default) pass, clippy clean, docs clean, shared replay 8/8 pass; the cache change touches no feature gates and no wire format.

  • 2026-11-04 (session 5, closeout): the plan's final checklist item closed and the whole wave reconciled. CHANGELOG 0.2.0's fuzzing entry now carries the delivered state: four targets (path_parse, encrypted_blob, derived_key_json, mnemonic_unlock), the fuzz/shared stable-replay posture, the nightly-only workspace excluded from the root workspace and the published package, and the findings summary (zero campaign findings; the two overflow panics and the max_entries = 0 cache bound bug came from the scope review, all regression-tested). No finding bullets were needed in the fixes section beyond those three, and the cache fix's bullet was already in place from session 4. Status line moved to done since every checklist item is checked. fuzz/README.md needed no further touch: the Targets table already lists all four targets and the commands section is accurate (the repeated-word wording fix from the review task was the only drift and was corrected in session 3). Verification: 114/107 tests (all-features/default) pass, clippy clean, docs clean, shared replay 8/8 pass, cargo publish --dry-run --all-features --allow-dirty clean.