uniffi 0.32 no longer depends on bincode (RUSTSEC-2025-0141) or paste (RUSTSEC-2024-0436), so their ignores go. basic-toml (RUSTSEC-2026-0374) stays: askama_derive 0.16 in uniffi_bindgen 0.32 still pulls it in. kv-ffi compiles unchanged; regenerated Swift/Kotlin bindings need no caller changes in kestrove-apple or kestrove-android. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Fuzzing and hardening report
libFuzzer targets (via cargo-fuzz) for every parser that sees untrusted input: data from the server,
the browser, the user, or the network. This file also records the October 2026 hardening pass across
all repos: fuzzing, dependency policy, CI and the deploy check.
Running
rustup toolchain install nightly && cargo install cargo-fuzz --locked
cd fuzz
cargo +nightly fuzz list
mkdir -p corpus/<target>
cargo +nightly fuzz run <target> corpus/<target> seeds/<target> -- -max_total_time=600
seeds/ holds a few valid inputs per target and is committed. corpus/, artifacts/ and logs/ are
gitignored. CI runs every target for 60 s from the seeds (the fuzz job in .github/workflows/ci.yml).
A crash input lands in artifacts/<target>/. Reproduce it with cargo +nightly fuzz run <target> <file>.
The server and desktop targets live in their own repos (kestrove-server/fuzz,
kestrove-desktop/src-tauri/fuzz) and run the same way.
Targets and results (2026-10-16, Apple M-series, 1 job per target, ASan)
| Target | Code under test | Checks beyond "no panic" | Time | Execs | Coverage (edges) | Result |
|---|---|---|---|---|---|---|
envelope_json |
kv_proto::Envelope JSON decode |
accepted ⇒ re-encode/decode round-trips | 8 min | 36.9 M | 2002 | clean |
manifest_decode |
kv_sync::manifest::ManifestBody::decode (strict canonical CBOR) |
accepted ⇒ input is exactly encode() |
8 min | 2.1 M | 711 | clean |
item_replay |
item plaintext → serde_json::Value → kv_model::Item; replay::check_pulled / check_successor / next_version; merge::merge3 |
identical record never a replay; next_version > seq |
8 + 8 min | 1.5 M + 2.9 M | 2248 | bug 2, fixed |
secret_key_parse |
kv_crypto::secret_key::SecretKey::parse |
accepted ⇒ display form parses to the same key | 8 + 8 min | 19.7 M | 423 | bug 1, fixed |
totp_parse |
kv_model::totp::Totp::parse (otpauth:// and bare base32), code_at, remaining |
code has digits ASCII digits; 1 ≤ remaining ≤ period |
8 min | 3.0 M | 1688 | clean |
url_match |
kv_model::match_url |
an https item never matches an http page | 8 min | 2.7 M | 15307 | clean |
server signature_headers |
Signature-Input, Signature, Content-Digest parsers (kestrove-server/src/sigparse.rs) |
accepted ⇒ header == kv= + params; nonce valid; fixed component list |
10 min | 59.0 M | 696 | clean |
desktop nmh_session |
whole kestrove-nmh connection into Broker::serve: framing, outer JSON, nmh_hello / ping / pair_init / pair_confirm / open / msg |
every reply is a valid frame with a t field |
8 min | 2.5 M | 2082 | clean |
desktop frame_read |
framing::read_frame (u32 length + payload, 1 MiB cap) |
frame ≤ MAX_FRAME; re-framing round-trips |
8 min | 2.7 M | 63 | clean |
Bug 1: SecretKey::parse panicked on non-ASCII input (fixed)
parse stripped separators, checked that the remainder was 27 bytes, then called split_at(26). A
multibyte character straddling byte 26 panicked (end byte index 26 is not a char boundary). Release
builds use panic = "abort", so a mistyped or pasted Secret Key containing, say, a Cyrillic е would
have crashed the app through FFI instead of showing "invalid Secret Key".
Fix: reject non-ASCII before the length check (one line in crates/kv-crypto/src/secret_key.rs).
Regression test: secret_key::tests::non_ascii_is_rejected_without_panicking, which uses the fuzzer's
crash input. It fails without the fix. Commit: "kv-crypto: SecretKey::parse rejects non-ASCII input
instead of panicking".
Bug 2: seq + 1 overflowed at u64::MAX (fixed)
replay::next_version and the engine's two merge paths (engine.rs, "edit beats delete" and the
three-way merge) computed max(seq) + 1 on seq values read from decrypted plaintext. A version carrying
"seq": 18446744073709551615 panicked in debug and fuzz builds. In release, which has no overflow checks,
it wrapped to seq 0, so the device's next save looked like a replay to every other device (an
integrity alarm). The 8-minute random run didn't produce a 20-digit number. A targeted input, written while
checking the target's next_version assertion, triggered it at once. Reaching it needs an authentic
plaintext (one written by a device holding the vault key), so severity is low.
Fix: saturating_add(1) in the three places. Regression test:
replay::tests::next_version_saturates_at_u64_max. The input is now a seed (seeds/item_replay/seq_max),
so CI's smoke run covers it. Commit: "kv-sync: saturate seq increments instead of overflowing at
u64::MAX".
kv-ffi's save_item has the same pattern on modified_at (p.modified_at + 1, i64). In release it
wraps to i64::MIN and now.max(…) absorbs it, so it's harmless there. It was left alone because the
passkeys branch is changing kv-ffi. Worth a saturating_add when that merges.
Notes
- The desktop's
msgpath only reacheshandle_requestwith a valid AES-GCM session, so the fuzzer covers the session layer's rejection paths, not request handling. That is the expected boundary: an unauthenticated peer can't get past it. - To fuzz
parse_signature_input, the pure header parsers moved fromkestrove-server/src/auth.rsintosrc/sigparse.rsunchanged. The server is a bin-only crate, and the fuzz crate includes that file by path.
Dependency policy
| Repo | Check | Result |
|---|---|---|
| kestrove-core | cargo deny check |
passes. allow-wildcard-paths for the workspace's own path crates. The uniffi 0.32 upgrade dropped bincode 1.x (RUSTSEC-2025-0141) and paste (RUSTSEC-2024-0436). Still ignored: RUSTSEC-2026-0374 (basic-toml, unmaintained), build-time only via askama_derive 0.16 in uniffi_bindgen 0.32. Not fixable here: it goes away when askama or uniffi_bindgen drops basic-toml. |
| kestrove-server | cargo deny check (new deny.toml) |
passes, no ignores |
| kestrove-desktop/src-tauri | cargo deny check (new deny.toml) |
passes. Also allows BSL-1.0 (clipboard-win/error-code via arboard on Windows) and CDLA-Permissive-2.0 (webpki-roots). Ignores the basic-toml notice (as core) plus RUSTSEC-2024-0370 (proc-macro-error via gtk-rs glib-macros 0.18, Tauri's Linux stack, build-time). Not fixable here: it goes away when Tauri moves to gtk-rs ≥ 0.19 / gtk4. |
| kestrove-browser | npm audit --omit=dev |
0 vulnerabilities. Dev-only: 1 moderate, esbuild ≤ 0.24.2 dev-server CORS issue (GHSA-67mh-4wv8-2f99). It only matters for esbuild serve, which the build script doesn't use, and none of it ships. |
| kestrove-desktop | npm audit --omit=dev and npm audit |
0 vulnerabilities |
| kestrove-android | Gradle dependency verification | new gradle/verification-metadata.xml pins sha256 for all 388 components used by assembleDebug test lint. The Linux and Windows aapt2 jars were added by hand: the macOS hash Gradle recorded matched dl.google.com. The 106 release-runtime artifacts were checked against OSV.dev: 0 advisories. |
CI (all repos)
All workflows pass actionlint (with shellcheck). Actions are pinned to commit SHAs with the version in a
comment, permissions: contents: read, persist-credentials: false. Sibling repos are checked out side by
side using secrets.CORE_READ_TOKEN || github.token. Add that secret (a fine-grained token with read
access) if the repos are private.
- server, desktop: the old workflows were invalid YAML (an unquoted
${{ }}inside a{ }flow mapping), so GitHub would have rejected them and never run anything. - core: adds a fuzz smoke job. Also fixed
scripts/build-android.sh, which ran uniffi-bindgen onlibkv_ffi.dylib, a file that doesn't exist on the Linux CI runner (.so). - server: adds fmt, cargo-deny, the fuzz smoke job and a
docker buildof the deploy image. - desktop: adds fmt and cargo-deny +
npm audit. The code had never been through rustfmt, so this commit adds arustfmt.toml(the same one as core and server) and a formatting-only commit. - apple: adds an
appjob: core → XCFramework, Safari JS, xcodegen, unsigned xcodebuild for iOS Simulator and macOS. Ran it locally (Xcode 27): both builds succeed.swift testfailed once under heavy load and then passed 4 times in a row. TheSyncControllertests inSyncTests.swiftrely on 100 ms debounces and fixed sleeps, so expect flakes on slow CI runners (follow-up). - android: gives the core checkout a token, adds Rust Android targets,
setup-gradle(wrapper validation) and lint report upload. - browser: Node 24 (as used locally), plus
npm audit --omit=dev.
Deploy check (kestrove-server/deploy, Docker Desktop 29.4, DOMAIN=localhost)
Passed after these fixes:
- The image never built. kv-proto inherits
edition/deps fromkestrove-core/Cargo.toml, which the Dockerfile didn't copy (error inheriting edition from workspace root). Now copied. Also removed the--locked || cargo buildfallback that silently ignored the lockfile, and addedDockerfile.dockerignore: the build context is the parent folder, and everytarget/andnode_modules/was being sent to the daemon. docker stopSIGKILLed the server (exit 137). It only listened for Ctrl-C, and as PID 1 it has no default SIGTERM action. It now shuts down on SIGTERM too and closes the pool, so the WAL is checkpointed (exit 0, singlekestrove.dbleft).- backup.sh never handed restic its credentials. It used
sourcewithoutexport, soRESTIC_*never reached restic. Fixed withset -a. It now also runssqlite3as the volume owner (uid 65532), so it can't leave root-owned-wal/-shmfiles. It runsPRAGMA integrity_checkon the copy before uploading, andKV_VOLUMEoverrides the volume name. - Smaller changes:
tmpfs: /tmpfor SQLite temp files underread_only: true, and Caddy dropsVia.
Verified: GET https://localhost/v1/server-info → 200 over HTTP/2 via Caddy's internal CA, with HSTS,
nosniff and no-referrer. / → 404. http:// → 308 to https. TLS 1.2 is refused. SETUP_TOKEN=… is
printed on first start. The container runs as uid/gid 65532 with ReadonlyRootfs=true, CapDrop=[ALL]
and no-new-privileges. Data files are owned by 65532. backup.sh ran in a Linux container standing in for
the VM host (real sqlite3, docker volume inspect and restic stubbed) while the server was live: the
.backup copy passed integrity_check with all migrations present.