Files
samiandClaude Opus 5.5 d45033f424
ci / test (push) Canceled after 0s
ci / wasm (push) Canceled after 0s
ci / deny (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s
Bump uniffi 0.28 -> 0.32.2; drop the bincode and paste deny ignores
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]>
2026-10-20 23:25:22 +09:00

10 KiB
Raw Permalink Blame History

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 msg path only reaches handle_request with 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 from kestrove-server/src/auth.rs into src/sigparse.rs unchanged. 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 on libkv_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 build of the deploy image.
  • desktop: adds fmt and cargo-deny + npm audit. The code had never been through rustfmt, so this commit adds a rustfmt.toml (the same one as core and server) and a formatting-only commit.
  • apple: adds an app job: core → XCFramework, Safari JS, xcodegen, unsigned xcodebuild for iOS Simulator and macOS. Ran it locally (Xcode 27): both builds succeed. swift test failed once under heavy load and then passed 4 times in a row. The SyncController tests in SyncTests.swift rely 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:

  1. The image never built. kv-proto inherits edition/deps from kestrove-core/Cargo.toml, which the Dockerfile didn't copy (error inheriting edition from workspace root). Now copied. Also removed the --locked || cargo build fallback that silently ignored the lockfile, and added Dockerfile.dockerignore: the build context is the parent folder, and every target/ and node_modules/ was being sent to the daemon.
  2. docker stop SIGKILLed 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, single kestrove.db left).
  3. backup.sh never handed restic its credentials. It used source without export, so RESTIC_* never reached restic. Fixed with set -a. It now also runs sqlite3 as the volume owner (uid 65532), so it can't leave root-owned -wal/-shm files. It runs PRAGMA integrity_check on the copy before uploading, and KV_VOLUME overrides the volume name.
  4. Smaller changes: tmpfs: /tmp for SQLite temp files under read_only: true, and Caddy drops Via.

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.