- 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.
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:
parse_derivation_path(src/derivation.rs): the hardened-index encodingindex + 0x80000000overflowedu32for any hardened index > 2147483647 (m/4294967295'): panic in debug, silent wrap into a meaningless index in release. Fixed (returnsDerivationError::InvalidPath); regression tests added.EncryptionKey::from_derived_bytes(src/encryption.rs): indexed&bytes[..32]without a length check and panicked on material shorter than 32 bytes. Fixed (returnsEncryptionError::InvalidLength); the signature change toResultwas approved for the unpublished 0.2.0; regression tests added.encryption_path_for_version(src/derivation.rs, found while writing thepath_parseinvariants): versions above0x80000001produced path strings the parser rejects after the overflow fix (a version-derived path failing at parse time). Fixed (returnsDerivationError::InvalidPathdirectly 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_pathhardened-index overflow + regression tests - Fix
EncryptionKey::from_derived_bytesshort-input panic (Result) + regression tests - Fix
encryption_path_for_versionhardened-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.tomlexcludes) path_parsetarget: shared invariants, fuzz target, seed corpus, stable replay testencrypted_blobtarget: shared invariants, fuzz target, seed corpus, stable replay testderived_key_jsontarget: shared invariants, fuzz target, seed corpus (include the string and byte-array redacted forms), stable replay testmnemonic_unlocktarget: 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>); reviewfuzz/artifacts/logs, no crashes/ooms/timeouts - Decide on
cache_opseq: deferred with rationale (Scope decisions table); themax_entries = 0bound bug fixed insrc/cache.rswith 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_parsetarget complete. Verification: 112/105 tests (all-features/default) pass, clippy clean, shared replay tests pass,cargo fuzz buildclean,cargo publish --dry-run --all-features --allow-dirtyclean. First detached campaign (path_parse, 193 s budget): ~440k executions, oom/timeout/crash 0/0/0, coverage 718 blocks. Next: theencrypted_blobtarget 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_blob73,473 3,118 8,187 649 0/0/0 derived_key_json2,090,033 2,175 7,668 1,536 0/0/0 mnemonic_unlock7,950,541 1,934 2,984 299 0/0/0 The
covfigures are libFuzzer's end-of-campaign counter, the same measure as thepath_parsebaseline's 718 (from its 193 s campaign);encrypted_blobis 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 atfuzz/artifacts/<target>-20261104-080728.log. Zero findings matches the task's expectation forderived_key_jsonandmnemonic_unlock(thin wrappers) and is a clean bill forencrypted_blobtoo. 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 afuzz/README.mdreword. 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_opseqdecided and checklist item 12 closed. Code review ofsrc/cache.rsconfirmed themax_entries = 0claim:inserttook theentries.len() >= max_entriesbranch (always true at 0), butevict_lrufound an emptyorderand 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:insertreturns early whenmax_entries == 0(cache disabled, key dropped and zeroized). Regression tests: the deterministic zero-bound test that failed before the fix, plus amax_entries = 1edge pin. The go/defer decision is Deferred with no target: the remaining candidate invariants (order/entrieskey-set consistency, LRU ordering) hold structurally by construction since everyentriesmutation pairs with anordermutation, and the deterministic suite already pins the LRU reorder, TTL eviction, replace, and clear interleavings, so a reference-model fuzzer (alkttysession_opseqshape) 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), thefuzz/sharedstable-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 themax_entries = 0cache 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.mdneeded 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-dirtyclean.