README.md

In formal/kani/ · At e15865e

e15865eHistory

Preview

Text · 13.1 KiB

mkit Kani harnesses

Bounded model checking of mkit's untrusted-input decoders with Kani (pin: Kani 0.68.0, CBMC 6.11.0). The harnesses live next to the code they check, in #[cfg(kani)] mod kani_proofs blocks:

FileSpec
rust/crates/mkit-core/src/delta.rsSPEC-DELTA §2, §4
rust/crates/mkit-core/src/merkle.rsSPEC-MERKLE-OBJECTS §1.1, §2, §5.2–§5.5
rust/crates/mkit-core/src/pack.rsSPEC-PACKFILE §1–§3, §6, §8, §11
rust/crates/mkit-core/src/serialize.rsSPEC-OBJECTS §2–§8, §4.1, §11
rust/crates/mkit-keystore/src/encrypted_record.rsSPEC-KEYSTORE §6.1.1
rust/crates/mkit-rpc/src/framing.rsSPEC-RPC §1 (wire framing, MAX_FRAME_BYTES)

Every result below is bounded: a harness checks every input up to the stated size (every byte symbolic unless said otherwise), not every input. Nothing here is a proof for unbounded inputs; the fuzz targets (docs/FUZZ.md) and proptests cover larger inputs by sampling.

Running

cargo install --locked kani-verifier --version 0.68.0 && cargo kani setup
cd rust

# mkit-core: always --no-default-features (with the default `pack-zstd`
# feature the C zstd dependency is linked in and CBMC ran out of memory on
# most harnesses). -Z stubbing is needed by every harness that uses
# #[kani::stub] (harmless for the others).
cargo kani -p mkit-core --no-default-features -Z stubbing --harness delta_decode_no_panic

# merkle_verify_*, merkle_roundtrip_*, merkle_canary_*, pack_*: add the
# 32-byte digest/trailer comparison bound
cargo kani -p mkit-core --no-default-features -Z stubbing \
  --harness pack_entries_one_frame \
  -Z unstable-options --cbmc-args --unwindset memcmp.0:33

cargo kani -p mkit-keystore -Z stubbing --harness software_key_record_roundtrip
cargo kani -p mkit-rpc --harness rpc_read_frame_no_panic

Run one harness at a time (--harness is a substring match; every name below is unique as a substring except where noted). Peak CBMC memory was ~5.4 GB (pack_window_cursor_decode); most harnesses stay under 2 GB.

Expected-outcome convention (same as the Quint check.sh scripts): a *_canary_* harness is #[kani::should_panic] and must report VERIFICATION:- SUCCESSFUL (encountered one or more panics as expected): the checker has falsified a deliberately wrong statement, which shows the property it mirrors is checkable at that bound. Every other harness must report VERIFICATION:- SUCCESSFUL. The two harnesses marked FINDING below failed until the production code was fixed (MKIT-56, MKIT-59, 2026-09-27); both now pass. kani::cover! sites must be satisfied (Kani prints N of N cover properties satisfied); they show the asserted Ok paths are reachable.

Results (2026-09-26, macOS arm64, 15 cores, shared with other jobs)

Time is wall-clock per cargo kani invocation including the (cached) build. Cap: 15 min per harness.

mkit-core: delta (--no-default-features -Z stubbing)

HarnessPropertyBoundResultTime
delta_decode_no_panicdecode never panics/overflows/reads OOB; Ok output length = header result_len (§2)base ≤ 4 B, stream ≤ 20 B, unwind 7pass, 3/3 covers51 s
delta_spec_headerdecode = §4 reference algorithm (bytes or error class)base ≤ 3 B, stream 8 and 9 Bpass5 s
delta_spec_insertas abovestream 10 and 11 Bpass, 1/1 cover450 s
delta_spec_copyas abovestream 16 B (header + one COPY)pass, 1/1 cover199 s
delta_decode_canary_wrong_lengthcanary: "output is one byte longer than declared"stream ≤ 20 Bfalsified (expected)51 s
delta_encode_decode_roundtripdecode(b, encode(b, r)) == rb, r ≤ 3 B each (INSERT-only writer path)pass37 s

mkit-core: serialize

HarnessPropertyBoundResultTime
serialize_prologue_no_panicprologue/dispatch/§11 trailing rule never panic, per-type readers stubbed nondeterministicallyinput ≤ 12 Bpass, 1/1 cover89 s
serialize_read_other_types_no_panicblob/commit/remix/tag/chunked/delta readers never panicbody ≤ 12 Bpass, 1/1 cover125 s
serialize_read_tree_one_entryread_tree in bounds; Ok ⇒ 1 entry matching the wire name_len/hash (§4.1 stubbed)42-B body, count pinned to 1pass, 1/1 cover33 s
serialize_validate_name_matches_specTreeEntry::validate_name = §4.1 modelnames ≤ 6 Bpass, 1/1 cover25 s
serialize_blob_roundtripdeserialize(serialize(blob)) == blobblob ≤ 4 Bpass95 s
serialize_canary_trailing_byte_acceptedcanary: "one trailing byte still deserializes"1-B blob + 1 Bfalsified (expected)40 s

mkit-core: merkle (BLAKE3 stubbed by a deterministic mixer)

HarnessPropertyBoundResultTime
merkle_proof_decode_no_panicProof::decode(_, 1) never panics and rejects allinput 0–4 Bpass (its covers are Ok sites, unreachable by design at < 5 B)49 s
merkle_proof_decode_empty_proofaccepts exactly be32(n) ‖ varint(0) (§5.2)5 Bpass, 1/1 cover11 s
merkle_proof_decode_one_sibling1-sibling proof decodes to the wire count/digest37 B, varint pinned to 1pass5 s
merkle_verify_s0fold succeeds iff pos < leaf_count and sibling count = §5.3; chunk pos 0 rejected (§5.5)0 siblings, any u32 leaf count/position, unwind 34pass92 s
merkle_verify_s1as above1 sibling, leaf count ≤ 8pass, 1/1 cover19 s
merkle_verify_s2as above2 siblings, leaf count ≤ 8pass, 1/1 cover23 s
merkle_roundtrip_one_chunkindependent §1.1/§5.3 builder's proof accepted by verify_chunk1 symbolic chunkpass11 s
merkle_roundtrip_two_chunksas above, both positions2 symbolic chunkspass26 s
merkle_canary_tampered_leaf_verifiescanary: "any leaf verifies under a genuine proof"2 chunksfalsified (expected)15 s
merkle_builder_empty_tree_refusesnew: §5.4 builder rule (added on this branch): every single-leaf, multi-leaf and range request against the empty Tree is refusedany u32 position/start/endpass after the MKIT-56 fix (FINDING 1; failed before with start = end = 0), 1/1 cover8 s

mkit-core: pack (BLAKE3 → toy_hash, zstd decompression stubbed)

HarnessPropertyBoundResultTime
pack_entries_one_framePackEntries::new + iteration never panic; on Ok: magic/version (§1), trailer (§8), v1 yields exactly entry_count items ending at the trailer (§3, §6), payload ranges inside the entry area (§2), raw-only ⇒ no delta5-B entry area, header/trailer symbolicpass273 s
pack_entries_two_entriesas above10-B entry areapass267 s
pack_writer_roundtrip_rawPackWriter → PackEntries round-trip, v1one 1-B raw entrypass144 s
pack_canary_mutation_still_parsescanary: "every 1-byte mutation of a valid pack still parses"2-B raw packfalsified (expected)34 s
pack_window_cursor_decodenew: windowed reader's resumable cursor decoder (WindowCursor::from_bytes, §11) never panics/overflows/reads OOB on a checksum-valid encoding with every numeric field and digest symbolic125-B body, layout A (anchor + prefix digests, no first-non-raw, empty trees)pass, 1/1 cover (acceptance reachable)636 s
pack_window_cursor_flip_rejectednew: a concrete valid cursor decodes iff unmodified; every nonzero XOR of its pack_len low byte is rejected256 maskspass589 s
pack_window_cursor_canary_flip_acceptedcanary: "a flipped pack_len byte still decodes"255 masksfalsified (expected)520 s

mkit-keystore (-Z stubbing; format! and from_utf8 stubbed)

HarnessPropertyBoundResultTime
software_key_record_decode_len_0decode never panics; input rejected0 Bpass62 s
software_key_record_decode_len_1_2as above1, 2 Bpass135 s
software_key_record_decode_len_3_4as above3, 4 Bpass131 s
software_key_record_decode_len_5_6as above5, 6 Bpass127 s
software_key_record_decode_len_7as above7 Bpass59 s
software_key_record_decode_magic_no_panicas above8, 9 Bpass134 s
software_key_record_decode_header_no_panicas above10, 11 Bpass119 s
software_key_record_decode_15b_no_panicas above15 Bpass58 s
software_key_record_roundtripdecode(encode(r)) == rempty variable fields, any algorithm 1–3, attrs, noncepass27 s
software_key_record_rejects_algorithm_4§6.1.1 "MUST reject id 0x04"minimal record, symbolic attrs/noncepass (default features)27 s
same, --features bls-thresholdas aboveas abovepass after the MKIT-59 fix (FINDING 2; failed before)62 s
software_key_record_canary_trailing_byte_acceptedcanary: "valid record + 1 trailing byte still decodes"59 + 1 Bfalsified (expected)21 s

mkit-rpc

HarnessPropertyBoundResultTime
rpc_read_frame_no_panicread_frame never panics; accepts iff full prefix, in-cap length and full body; BodyTruncated.actual is the true countstreams ≤ 6 B, opaque messagepass17 s
rpc_canary_over_cap_length_acceptedcanary: "an over-cap length prefix is accepted"4-B prefixfalsified (expected)6 s

Findings

  1. SPEC-MERKLE-OBJECTS §5.4 builder rule vs BmtTree::range_proof. The spec now says a builder asked for position 0 of an empty Tree, "including the range 0..=0, MUST refuse rather than return the all-default proof". merkle.rs BmtTree::range_proof still has if self.empty { if start == 0 && end == 0 { return Ok(Proof::default()); } ... }, so build_tree_entries_range_proof(&Tree { entries: vec![] }, 0, 0) returns Ok(Proof { leaf_count: 0, siblings: [] }). merkle_builder_empty_tree_refuses reports this counterexample (start = 0, end = 0; the single-leaf and multi-leaf builders refuse correctly). Fixed (MKIT-56): range_proof now returns PositionOutOfRange(start) for every range of the empty tree, an intentional divergence from upstream commonware_storage::bmt (commented in the code; the commonware cross-check draws n >= 1 only). Regression test merkle::tests::empty_tree_builders_refuse_position_zero; the harness passes (2026-09-27: 0 of 4628 failed, 1/1 cover, 7.5 s).
  2. SPEC-KEYSTORE §6.1.1 algorithm id 0x04 under bls-threshold. The spec says a MKITKSV1 decoder MUST reject id 0x04. algorithm_from_id maps 4 => Algorithm::Bls12381Threshold when the bls-threshold feature is on, so EncryptedKeyRecord::decode accepts such a record; rejection only happens later, in decrypt's plaintext length check. software_key_record_rejects_algorithm_4 held in the default build and failed with --features bls-threshold. Fixed (MKIT-59): §6.1.1 and §6.1.2 are explicit that shares use the separate MKITKSB1 record, so algorithm_from_id now rejects 4 in every build (no spec change). Regression test encrypted_record::tests::encrypted_record_decode_rejects_reserved_algorithm_4 (failed under bls-threshold before the fix); the harness passes in both configurations (2026-09-27: 0 of 771 failed each).

Dropped or narrowed (did not finish within 15 min)

  • WindowCursor decoding with every body byte symbolic (125 B): symbolic option tags or tree depths make CBMC explore every layout after them. The harnesses fix the layout (tag and depth bytes) and keep every value symbolic. Invalid-tag and invalid-depth rejection is not covered here.
  • A second layout (pack-id-bound cursor with a first-non-raw index), and the canonical re-encoding check (to_bytes(from_bytes(b)) == b): both timed out (900 s). Each from_bytes call costs ~7 min of symbolic execution (~6.8M steps), and a harness with two calls did not finish.
  • PackReader::read (needs an on-disk ObjectStore), builder-side build_chunk_proof / compute_chunked_id, buffa message decoding in read_frame, non-empty keystore round-trip fields, and multi-entry trees: see the doc comments on each module. They are left to the fuzz targets and proptests.
  • The §3.3 writer rule added on this branch ("a writer MUST NOT emit a 0x03/0x04 entry whose uncompressed_len would exceed MAX_RAW_OBJECT_SIZE") is enforced in maybe_compress_capped, behind the pack-zstd C compressor that Kani cannot model. It is not model-checked. append_raw_frame itself does not re-check the claim against the cap; it relies on maybe_compress.

Harness fixes in this pass

  • Every stub-using harness needs -Z stubbing under Kani 0.68 (the earlier runs did not document it). Without it compilation fails with "Using the stub attribute requires activating the unstable stubbing feature".
  • encrypted_record.rs: #[allow(invalid_from_utf8)] on the from_utf8 stub, which builds its error value from a known-invalid literal.

Files