Add cargo-fuzz targets for the untrusted-input parsers and the hardening report
envelope_json, manifest_decode, item_replay, secret_key_parse, totp_parse, url_match, with seeds. fuzz/README.md records the runs (and the server/desktop targets), the two bugs fixed in the previous commits, dependency-policy results, CI changes and the deploy check. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
corpus/
|
||||
artifacts/
|
||||
coverage/
|
||||
logs/
|
||||
Generated
+2266
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
[package]
|
||||
name = "kestrove-core-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
serde_json = "1"
|
||||
uuid = "1"
|
||||
kv-proto = { path = "../crates/kv-proto" }
|
||||
kv-crypto = { path = "../crates/kv-crypto" }
|
||||
kv-model = { path = "../crates/kv-model" }
|
||||
kv-sync = { path = "../crates/kv-sync" }
|
||||
|
||||
# Not part of the core workspace (crates/*); built only by `cargo +nightly fuzz`.
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
|
||||
[[bin]]
|
||||
name = "envelope_json"
|
||||
path = "fuzz_targets/envelope_json.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "manifest_decode"
|
||||
path = "fuzz_targets/manifest_decode.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "item_replay"
|
||||
path = "fuzz_targets/item_replay.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "secret_key_parse"
|
||||
path = "fuzz_targets/secret_key_parse.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "totp_parse"
|
||||
path = "fuzz_targets/totp_parse.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "url_match"
|
||||
path = "fuzz_targets/url_match.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# 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
|
||||
|
||||
```sh
|
||||
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. Ignored: RUSTSEC-2025-0141 (bincode 1.x) and RUSTSEC-2024-0436 (paste), both *unmaintained* notices for build-time crates pulled in by uniffi 0.28's proc-macros and bindgen. **Not fixable here:** the fix is a uniffi upgrade, which regenerates the Swift and Kotlin bindings for both apps. |
|
||||
| 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 two uniffi notices 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.
|
||||
@@ -0,0 +1,12 @@
|
||||
//! kv-proto Envelope JSON decode: anything accepted must re-encode and decode to the same value.
|
||||
#![no_main]
|
||||
use kv_proto::Envelope;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(e) = serde_json::from_slice::<Envelope>(data) {
|
||||
let again = serde_json::to_vec(&e).expect("re-encode");
|
||||
let back: Envelope = serde_json::from_slice(&again).expect("decode own encoding");
|
||||
assert_eq!(back, e);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Decrypted item plaintext: JSON parse, Item model parse, and the per-item replay checks and merge that
|
||||
//! run on every pulled record. Input: two plaintexts (held base, incoming) separated by a 0xFF byte.
|
||||
#![no_main]
|
||||
use kv_model::Item;
|
||||
use kv_sync::{merge::merge3, replay};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (a, b) = match data.iter().position(|&c| c == 0xff) {
|
||||
Some(i) => (&data[..i], &data[i + 1..]),
|
||||
None => (data, &data[..0]),
|
||||
};
|
||||
let Ok(incoming) = serde_json::from_slice::<Value>(a) else { return };
|
||||
let _ = serde_json::from_value::<Item>(incoming.clone());
|
||||
let id = Uuid::from_u128(1);
|
||||
let held: Option<Value> = serde_json::from_slice(b).ok();
|
||||
let base = match &held {
|
||||
Some(v) => replay::Base::Sealed(v),
|
||||
None => replay::Base::LegacyTombstone,
|
||||
};
|
||||
for deleted in [false, true] {
|
||||
let _ = replay::check_pulled(base, id, deleted, Some(&incoming));
|
||||
}
|
||||
// An identical record is never a replay.
|
||||
assert!(replay::check_successor(replay::Base::Sealed(&incoming), &incoming).is_ok());
|
||||
let next = replay::next_version(Some(&incoming), base);
|
||||
assert!(next > replay::seq_of(&incoming) || replay::seq_of(&incoming) == u64::MAX);
|
||||
if let Some(h) = &held {
|
||||
let _ = merge3(None, h, &incoming);
|
||||
let _ = merge3(Some(h), &incoming, h);
|
||||
let _ = replay::same_content(h, &incoming);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
//! kv-sync ManifestBody::decode (strict canonical CBOR): accepted input must be exactly its own encoding.
|
||||
#![no_main]
|
||||
use kv_sync::manifest::ManifestBody;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(body) = ManifestBody::decode(data) {
|
||||
assert_eq!(body.encode(), data, "non-canonical input accepted");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
//! kv-crypto SecretKey::parse: never panics; an accepted key round-trips through its display form.
|
||||
#![no_main]
|
||||
use kv_crypto::secret_key::SecretKey;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(s) = std::str::from_utf8(data) else { return };
|
||||
if let Ok(k) = SecretKey::parse(s) {
|
||||
let again = SecretKey::parse(&k.to_display()).expect("parse own display form");
|
||||
assert_eq!(again.expose(), k.expose());
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
//! kv-model TOTP / otpauth:// parse, then code generation at fuzzed times.
|
||||
#![no_main]
|
||||
use kv_model::totp::Totp;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if data.len() < 8 {
|
||||
return;
|
||||
}
|
||||
let t = u64::from_le_bytes(data[..8].try_into().unwrap());
|
||||
let Ok(s) = std::str::from_utf8(&data[8..]) else { return };
|
||||
if let Ok(totp) = Totp::parse(s) {
|
||||
let code = totp.code_at(t);
|
||||
assert_eq!(code.len(), totp.digits as usize);
|
||||
assert!(code.bytes().all(|c| c.is_ascii_digit()));
|
||||
let r = totp.remaining(t);
|
||||
assert!(r >= 1 && r <= totp.period);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
//! kv-model URL matching: never panics, a URL always matches itself at least at host level, and an
|
||||
//! https item never matches an http page.
|
||||
#![no_main]
|
||||
use kv_model::{match_url, MatchLevel};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(s) = std::str::from_utf8(data) else { return };
|
||||
let (a, b) = s.split_once('\n').unwrap_or((s, ""));
|
||||
let m = match_url(a, b);
|
||||
if b.trim_start().to_ascii_lowercase().starts_with("http://")
|
||||
&& a.trim_start().to_ascii_lowercase().starts_with("https://")
|
||||
{
|
||||
assert_eq!(m, MatchLevel::None, "downgrade matched: {a:?} -> {b:?}");
|
||||
}
|
||||
let _ = match_url(a, a);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1,"alg":"hpke-x25519-chacha","kid":"00000000-0000-0000-0000-000000000001","ct":"AAEC","enc":"AAEC"}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1,"alg":"xc20p","kid":"00000000-0000-0000-0000-000000000001","n":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ct":"AAEC"}
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"00000000-0000-0000-0000-000000000001","kind":"login","title":"t","username":"u","password":"p","urls":["https://example.com"],"created_at":1,"modified_at":2,"seq":3}ÿ{"id":"00000000-0000-0000-0000-000000000001","kind":"login","title":"t","username":"u","password":"p","urls":["https://example.com"],"created_at":1,"modified_at":2,"seq":2}
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"00000000-0000-0000-0000-000000000001","seq":18446744073709551615}˙{"seq":18446744073709551614}
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"00000000-0000-0000-0000-000000000001","seq":4,"deleted":true}ÿ{"id":"00000000-0000-0000-0000-000000000001","kind":"login","title":"t","username":"u","password":"p","urls":["https://example.com"],"created_at":1,"modified_at":2,"seq":3}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
K1-000000-00000-00000-00000-00000-0
|
||||
@@ -0,0 +1 @@
|
||||
JBSW Y3DP EHPK 3PXP
|
||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
https://login.example.co.uk:8443/a
|
||||
example.co.uk
|
||||
@@ -0,0 +1,2 @@
|
||||
http://[::1]:80/
|
||||
https://xn--bcher-kva.example/
|
||||
Reference in New Issue
Block a user