Skip to content

feat: on-disk encryption (XChaCha20-Poly1305, envelope keys, O(1) rotation) - #85

Merged
Xof merged 43 commits into
mainfrom
feature/on-disk-encryption
Jul 1, 2026
Merged

feat: on-disk encryption (XChaCha20-Poly1305, envelope keys, O(1) rotation)#85
Xof merged 43 commits into
mainfrom
feature/on-disk-encryption

Conversation

@Xof

@Xof Xof commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds opt-in, client-supplied authenticated on-disk encryption. A client supplies a key when opening a database (Options::encryption_key); every byte Chisel then writes is encrypted and integrity-protected. Without a key, databases are plaintext exactly as before — byte-for-byte identical, zero regression.

Design

  • XChaCha20-Poly1305 AEAD — a fresh random 192-bit nonce per page write, AAD = page_id (anti-relocation). Authenticated (not merely confidential) so tampering is cryptographically detected. Random nonces are crash-safe under shadow paging; a deterministic (page_id, counter) nonce would risk keystream reuse when a crashed transaction returns page_ids to the freemap while the durable counter has not advanced.
  • Envelope keys — a random per-database DEK seals every page and the sensitive superblock fields; the DEK is stored wrapped under a KEK derived from the client key (HKDF-SHA256 for raw keys, Argon2id for passphrases) in an 8-slot key-slot table in the superblock's plaintext reserved region. Credential rotation re-wraps the DEK — O(1), no data re-encryption.
  • 8232-byte on-disk stride (8192 ciphertext + 16 tag + 24 nonce); the logical page stays 8192, so the freemap / data-page / handle-table geometry is untouched — encryption is a transform at the page-I/O seam (PageCache owns a PageCipher; page_io is stride-aware but crypto-agnostic). Encrypted DBs use this uniform stride from birth, including superblock slots; open bootstraps by reading page 0 at offset 0, learning the stride from its plaintext crypto-header, then reading the rest.
  • Format gate: MAJOR bump 1 → 2 for encrypted DBs (the first exercise of the MAJOR tier). Encryption-unaware binaries refuse a MAJOR=2 file; plaintext DBs stay MAJOR=1.
  • Encrypts everything an encrypted DB writes: data pages, the sensitive superblock body (including the user-chosen named_roots), and the spillway (seal-once on evict, verbatim ciphertext copy on drain — never plaintext).
  • Key management (Rust + Python): add_key / rotate_key / remove_key. Rotation stages the new key before revoking the old (no zero-key window, even across a crash); remove_key refuses the last active slot (brick prevention). Persisted via an atomic metadata-only superblock commit.

Design spec: docs/specs/2026-06-29-on-disk-encryption-design.md. Decision + rejected alternatives recorded as ADR-15.

What changed

  • New src/crypto/ (cipher, KDF, DEK wrap/unwrap, zeroizing key types), src/superblock/crypto_header.rs (key-slot table + sealed body), src/transaction/keys.rs (rotation + the metadata-only commit).
  • page_io / page_cache / spillway made stride-aware with seal-on-write / open-on-read.
  • Public API: Key, Argon2Params, Options::encryption_key / argon2_params, the encryption error variants, and add_key/rotate_key/remove_key. Internal crypto/superblock items are pub(crate).
  • Python bindings: encryption_key kwarg, the three rotation methods, and the six encryption exception classes.
  • Plaintext code paths are unchanged (every divergence is a Some/None cipher branch).

Testing

  • 680 tests pass; cargo clippy --all-targets -- -D warnings clean on both the lib and chisel-py.
  • Coverage: create→write→reopen→read round-trip, wrong/missing key, key-on-plaintext, passphrase, rotation (add / rotate / remove — including no-zero-key-window and last-slot refusal), spill+drain under encryption, never-committed-DB reopen, torn-slot-0 recovery, version-gate rejection, KDF known-answer (HKDF RFC 5869 + an Argon2id vector), tamper→DecryptionFailed, anti-relocation, and a plaintext byte-identity regression guard.
  • Python runtime tests run in CI (3.11 / 3.13 via maturin); the Rust binding is type-checked + clippy-clean.

Threat model (documented boundaries)

Provides confidentiality, per-page and superblock tamper-detection, and anti-relocation. Does not provide rollback/replay resistance (an attacker substituting a wholly older, validly-signed image needs an external trust anchor to detect), in-memory DEK protection beyond zeroize-on-drop, or size/traffic-pattern hiding.

Deferred

Bulk DEK rotation — full re-encryption of every page under a fresh DEK, for the "the DEK itself is compromised" case — is deferred as a heavy whole-file operation (ISSUES.md I142). It is distinct from the implemented O(1) credential (KEK) rotation.

Xof added 30 commits June 29, 2026 18:48
…ncryptionKey, EncryptionNotSupported, NoFreeKeySlot, LastKeySlot, fatal DecryptionFailed)
- Remove derive(Debug) from Kek: Zeroizing's derived Debug delegates to
  the inner array without redaction, so it would print raw key bytes.
- Make the intermediate KDF output buffer Zeroizing and move it into Kek,
  so no un-wiped copy of derived key material lingers on any path.
Add seal_detached/open_detached AEAD helpers (reused by Task 1.5 PageCipher)
and the public wrap_dek/unwrap_dek API. Detached mode keeps wrapped-DEK output
the same 32-byte length as plaintext with a separate 16-byte Poly1305 tag,
matching the fixed key-slot layout. AAD binds slot metadata so slot parameters
cannot be silently tampered. Any auth failure returns CryptoError::Auth with no
partial plaintext leakage; intermediate key bytes kept in Zeroizing buffers.

Five new tests: round-trip, wrong KEK, tampered tag, tampered ciphertext, wrong
AAD — all use matches!/is_err() to avoid requiring Dek: Debug (by design).
open_detached returned a plain Vec<u8> holding the decrypted DEK; the copy into
a Zeroizing array in unwrap_dek wiped only the copy, leaving the original heap
buffer returned to the allocator un-scrubbed. Return Zeroizing<Vec<u8>> so the
plaintext is wiped on drop.

Also correct the open_detached comment: XChaCha20-Poly1305 is verify-then-decrypt
(tag checked before keystream applied), so on auth failure the buffer still holds
ciphertext and no plaintext is written — the previous "scrubs on failure" claim
was wrong. Move the chacha20poly1305 imports to the top-of-file import group.
…gion

Convert superblock.rs to a module directory and add
superblock/crypto_header.rs with the KeySlot + CryptoHeader codec.

On-disk layout occupies bytes 324–1356 in the superblock's reserved
region (well inside CHECKSUM_OFFSET 8184): an 8-byte preamble (algorithm
u8 + stride u32 + 3 reserved bytes) followed by 8 × 128-byte key slots.
Algorithm byte 0 → plaintext DB (deserialize returns None); algorithm 1
→ XChaCha20-Poly1305.

Also derives PartialEq + Eq on Argon2Params, required by KeySlot's own
PartialEq derive.
Adds `pub encryption: Option<CryptoHeader>` to `Superblock`. For encrypted
DBs, `serialize_encrypted` seals all sensitive fields (root pointers,
total_pages, next_handle, freemap_depth, named_roots) under the DEK into
a nonce||tag||len||ct blob at SEALED_BODY_OFFSET (1356), leaving those
byte ranges zero in the on-disk page. `deserialize` reads the crypto-header
first; for encrypted DBs it returns the struct with zero sensitive fields
and populated `encryption` field, deferring fill to `decrypt_body` once
the DEK is known. Plaintext DBs use the unchanged plaintext path.

Also adds `sb_identity_aad` (AAD = magic|format_version|txn_counter|
superblock_count, preventing body transplant between DBs) and updates
all Superblock struct literals (including commit.rs and recovery_tests.rs)
with `encryption: None`.
The page checksum is non-cryptographic XXH3, so a forged page can carry an
out-of-range ct_len whose slice runs past CHECKSUM_OFFSET and panics before
open_body's AEAD auth can reject it. Guard the slice: an out-of-range length
now returns CryptoError::Auth (the same undecryptable-body error as a failed
auth), so a corrupt sealed body is recoverable rather than a process crash.

Also: assert the membership-index and freemap_depth byte ranges (312..324)
are zero in the encrypted page (sealed-only, must not leak in cleartext), and
add a debug_assert_eq on load_body's input length to document the BODY_LEN
invariant. New test forged_ct_len_returns_err_not_panic regresses the panic.
…d-body decrypt

The open_existing path reconstructed the winner's buffer index using
txn_counter % superblock_count, which matches the commit write-slot rule
but is inverted for the create_new seeding rule (slot i gets counter N-1-i).
For a freshly created, never-committed encrypted DB the winner (counter N-1)
sits at page 0, but (N-1) % N = N-1 points at the loser slot, so decrypt_body
built its AAD from the winner's txn_counter but read the sealed body from the
wrong buffer -- AAD mismatch -- CryptoError::Auth -- correct key wrongly fails.

Fix: replace slot_idx/raw derivation with filter_map + max_by_key over
(deserialized_superblock, &buffer) pairs so raw is provably the same buffer
the winner was deserialized from, regardless of seeding vs commit ordering.
Superblock::select() (which discards the buffer) is now #[cfg(test)] only.

Also fix the UnsupportedFormatVersion expected field: report
format_version_encrypted() for an encrypted DB's MAJOR mismatch instead of
the plaintext FORMAT_VERSION, which was misleading for a MAJOR=2 file.

Regression test: open_encrypted_db_with_no_commits_uses_correct_key confirms
a correct key opens a never-committed encrypted DB (the exact broken scenario).
…al invariants

Add two unit tests (encrypted_manager_holds_session_cipher,
plaintext_manager_has_no_cipher) confirming that TransactionManager
retains Option<PageCipher> correctly after create_new: Some for an
encrypted DB, None for a plaintext DB. DEK zeroization on drop is
automatic via Zeroizing<[u8; 32]> inside PageCipher; safe Rust cannot
observe post-drop memory, so the tests assert the structural invariant
rather than attempting to read freed memory.

Task 2.4 already completed all implementation (cipher field, constructor
wiring, call-site updates). This task is verification + targeted tests.
…192 plaintext)

- Backing::Memory switches from Vec<[u8; PAGE_SIZE]> to flat Vec<u8>
  addressed by page_id * stride, enabling variable on-disk unit sizes.
- PageIo gains a stride field (default PAGE_SIZE). New set_stride() /
  stride() accessors let the engine switch to ENC_PAGE_SIZE (8232) after
  reading the plaintext page-0 bootstrap header on an encrypted open.
  set_stride re-seeds the I51 cached page count in the new unit size.
- New read_page_unit() / write_page_unit() move stride-byte blobs.
  Crypto-agnostic: for encrypted DBs the blob is sealed ct|tag|nonce;
  PageCache handles seal/open (Task 3.3). For plaintext stride==PAGE_SIZE
  and the blob is the page image itself.
- read_page() / write_page() become thin wrappers over the unit functions
  (debug_assert-guarded to catch any accidental encrypted-path call).
- set_page_count() now sets file length to n*stride rather than n*PAGE_SIZE.
- 6 new stride_tests cover: 8232 round-trip, default stride, wrong-length
  error, set_stride reseeds count, set_page_count uses stride, plaintext
  wrappers unchanged.
…port

Widens the spillway slot from the fixed PAGE_SIZE (8192) plaintext
payload to a runtime-configurable payload_size, enabling it to carry
the 8232-byte ENC_PAGE_SIZE sealed blob for encrypted DBs.

Changes:
- Spillway::open_file / open_memory gain a payload_size: usize param
  (PAGE_SIZE for plaintext, ENC_PAGE_SIZE for encrypted).
- SLOT_SIZE const retained as the plaintext-default for existing test
  sizing; runtime slot arithmetic now uses SLOT_HEADER_SIZE + payload_size.
- spill() accepts &[u8] instead of &[u8; PAGE_SIZE]; rehydrate() returns
  Vec<u8> instead of [u8; PAGE_SIZE] — crypto-agnostic: sealed blob
  stored and returned verbatim.
- logical_bytes() and SpillwayFull cap charge against payload_size.
- page_cache.rs: pass PAGE_SIZE at open_file/open_memory; convert
  rehydrate Vec<u8> back to [u8; PAGE_SIZE] via try_into (plaintext
  path); .as_ref() on spill call to coerce Box<[u8; N]> to &[u8].
- New tests: wide_slot_round_trips_sealed_blob and
  wide_slot_checksum_catches_tampered_payload verify 8232-byte round
  trips and checksum detection at ENC_PAGE_SIZE.
…sk 3.3)

Encrypt data pages at flush, decrypt at cold load, and make an encrypted
database use the ENC_PAGE_SIZE (8232) stride for every page — superblock
slots included — from creation.

PageCache:
- Add Option<PageCipher>; entries always hold plaintext, the main file and
  spillway hold ciphertext.
- write_sealed() seals at flush (Phase 1a dirty-write and 1b drain) when a
  cipher is present, else writes the plaintext unit directly.
- load_page cold path reads the on-disk unit into a stack [u8; ENC_PAGE_SIZE]
  (zero heap alloc via read_page_unit_into), opens it through the cipher, then
  verifies the checksum on the recovered plaintext. A CryptoError maps to the
  fatal DecryptionFailed.

page_io: add read_page_unit_into (zero-alloc cold-load read); drop the
now-dead allow(dead_code) on stride()/set_stride().

Uniform-stride layout:
- create_new sets the 8232 stride BEFORE writing the initial superblock slots
  (each 8192-byte image zero-padded into an 8232-byte unit), so a fresh or
  never-committed encrypted file is a clean multiple of the stride and
  page_count == total_pages at open.
- commit.rs writes the superblock into a zero-padded 8232-byte unit for
  encrypted DBs (write_page would panic on the stride assert).
- open_existing anchors on page 0 (always at offset 0), learns the stride from
  its cleartext crypto-header, then reads all slots at that stride before the
  file-size validation. A torn slot 0 (no slot deserializes at the default
  stride) speculatively retries the scan at the encrypted stride so R4 sibling
  recovery still works for encrypted DBs.
- FileSizeMismatch reports stride-correct byte counts.

crypto: derive Clone on PageCipher (cache and session manager each hold an
independent zeroizing DEK copy); remove the module-level allow(dead_code).

Tests: encrypted page seal/open round-trip, tamper -> DecryptionFailed,
never-committed N=4 reopen, multi-page value round-trip, and torn-slot-0
sibling recovery (confirmed to fail when the stride fallback is disabled).
Closes the plaintext-spill leak flagged in the Task 3.3 report.

- ensure_spillway: select payload_size=ENC_PAGE_SIZE when a cipher is
  present so the spillway slot is sized for the sealed blob from the start.
- maybe_evict Phase B: seal the plaintext page once (via PageCipher::seal)
  before handing the ciphertext blob to Spillway::spill. The spillway now
  NEVER holds plaintext for an encrypted DB.
- flush Phase 1b drain: the spillway slot already holds the sealed blob;
  write it verbatim to the main file via io.write_page_unit (no second
  seal, no new nonce). Open the blob to obtain plaintext for the cache
  re-insertion that follows drain.
- load_page spillway branch (rehydrate path): open the sealed blob back
  to plaintext before re-inserting the still-dirty page into the cache.
- Plaintext DBs: payload_size stays PAGE_SIZE; no cipher branch taken;
  byte-identical to the pre-3.4 behaviour.

Five new tests in page_cache::tests:
  encrypted_spill_stores_ciphertext_not_plaintext
  encrypted_spill_drain_and_cold_read_round_trips
  encrypted_rehydrate_returns_correct_plaintext
  encrypted_spill_full_round_trip_multiple_pages
  plaintext_spill_drain_unchanged
…ions

- Make Options::encryption_key pub (was pub(crate)); callers no longer
  need to name crate::crypto::Key to use it
- Add Options::argon2_params: Option<Argon2Params> field and builder for
  caller-supplied Argon2id cost parameters on passphrase-key creates
- Rename Options::with_encryption_key -> encryption_key to match the
  field-name-as-builder convention used by all other Options setters
- Thread argon2_params through create_new -> build_create_cipher so
  caller-supplied params actually override the OWASP default for
  Key::Passphrase creates
- Add tests/public_key_api.rs proving Key/Argon2Params are reachable
  from the public surface without naming crate::crypto internals
- Add options_encryption_tests unit test in src/lib.rs
Exposes chisel::Key to Python via the pyo3 open() binding. bytes →
Key::Raw(Zeroizing::new(...)), str → Key::Passphrase(Zeroizing::new(...)),
anything else → TypeError. Key material is wrapped in Zeroizing immediately
under the GIL before py.detach.

Also adds the four encryption exception classes (Task 4.5 scope folded in so
the test file runs end-to-end): NoEncryptionKeyError, InvalidEncryptionKeyError,
EncryptionNotSupportedError (OperationalError), DecryptionFailedError
(FatalError). Stubs and __init__.py updated.
Add group 12 to test_exception_contract.py pinning the four encryption
exception classes (concrete class + tier base). NoEncryptionKeyError,
InvalidEncryptionKeyError, EncryptionNotSupportedError triggered end-to-end;
DecryptionFailedError covered by FatalError hierarchy check only (per-page
MAC tampering not reachable from pure Python, matching the existing fatal-arm
precedent).

Drop unused derive feature from the zeroize dep (only Zeroizing::new used).
Comment the intentional bare open() in test_encryption.py.
Adds TransactionManager::rewrite_crypto_header (src/transaction/keys.rs),
which persists a modified CryptoHeader via the ordinary A/B superblock
rotation — no data pages touched, DEK unchanged. Guards against poisoned
manager, active transaction, and plaintext DB. Uses the same ENC_PAGE_SIZE
zero-padding + write_page_unit path that commit.rs uses for encrypted DBs,
so the on-disk stride invariant is maintained. Seven unit tests cover all
guards, in-memory state promotion, root preservation, slot alternation,
and full on-disk reopen durability.
O(1) credential rotation via DEK re-wrap: add_key stages a second wrapped
copy of the existing DEK into a free key slot; rotate_key does the same
then clears the old slot in the same single atomic superblock rewrite so
there is never a window where zero credentials unlock the database.

Remove #[allow(dead_code)] from rewrite_crypto_header (now has callers).
8 integration tests in tests/encryption_keys.rs cover both methods across
plaintext DB, wrong credential, full slot table, and round-trip data
readability.
Xof added 11 commits June 30, 2026 19:43
…ror/LastKeySlotError

- errors.rs: create_exception! for NoFreeKeySlotError and LastKeySlotError
  (both OperationalError); add to_py_err arms for NoFreeKeySlot/LastKeySlot
  variants; register both in module init; update hierarchy comment.
- db.rs: add module-level py_key() helper (bytes→Key::Raw, str→Key::Passphrase,
  else TypeError); add three #[pymethods]: add_key(existing, new),
  rotate_key(old, new), remove_key(key) — each coerces via py_key then
  calls with_inner_mut_io.
- chisel/__init__.py: import + __all__ entries for both new error classes.
- chisel/chisel.pyi: stubs for the two new error classes and three new
  Chisel methods.
- python/tests/test_encryption_keys.py: new test file — add_key (both
  keys open, passphrase alongside raw), rotate_key (old revoked, two-slot),
  remove_key (revokes slot, other key works), LastKeySlotError, NoFreeKeySlotError,
  bad key type TypeError.
- python/tests/test_exception_contract.py: section 13 — end-to-end triggers
  for both new exception classes, tier (OperationalError) hierarchy pin.
…ion test

Adds FORMAT_MINOR_VERSION_ENCRYPTED (0) and ENCRYPTED_FORMAT_VERSION
(pack(2,0)) to page.rs alongside the existing FORMAT_MAJOR_VERSION_ENCRYPTED.
Exports both new constants from lib.rs.

Adds encrypted_major_version_is_rejected_by_plaintext_binary in a new
#[cfg(test)] mod in recovery.rs: writes a superblock with format_version=2
(no crypto header), opens without a key, and asserts UnsupportedFormatVersion
is returned — proving the format gate hard-rejects MAJOR=2 files on an
encryption-unaware path, exactly as the on-disk-encryption spec §7/§8 require.
… pack(2,0)

The first pass left two representations of the encrypted format version that
disagreed on the MINOR byte: format_version_encrypted() returned pack(2, 1)
while the new ENCRYPTED_FORMAT_VERSION const was pack(2, 0). Collapse to one:

- format_version_encrypted() now returns ENCRYPTED_FORMAT_VERSION (pack(2, 0)).
  A new MAJOR series correctly starts its MINOR count at 0. Create, commit,
  open-gate, and the superblock-body AAD all route through this one function,
  so there is exactly one on-disk value.
- Scope FORMAT_MINOR_VERSION_ENCRYPTED and ENCRYPTED_FORMAT_VERSION to
  pub(crate); revert the lib.rs re-export to the pre-task public surface.
- Gate-rejection test now asserts the exact stamped value (0x0002_0000).
…rate)

Items added by the on-disk encryption feature (CryptoHeader, KeySlot,
ALGO_XCHACHA20POLY1305, CRYPTO_HEADER_OFFSET, KEY_SLOT_COUNT, KEY_SLOT_SIZE,
FORMAT_MAJOR_VERSION_ENCRYPTED, format_version_encrypted, PageCipher,
CryptoError, KdfId, derive_kek, wrap_dek, unwrap_dek, NONCE_LEN, SALT_LEN,
DEK_LEN) were incorrectly exported on the crate's public surface.

These items are internal implementation details scoped inside pub(crate)
modules (crypto, superblock); they do not need explicit re-exports in lib.rs.
Removing the pub use lines from lib.rs confines them to pub(crate) access,
which is all in-crate callers need.

Pre-existing public items left unchanged: SlotDefect, SuperblockDefect,
DEFAULT_SUPERBLOCK_COUNT, MAX_SUPERBLOCKS, MIN_SUPERBLOCKS, NAMED_ROOT_COUNT,
NAMED_ROOT_NAME_LEN, format_major (all public before this branch).
Public API additions: Key and Argon2Params (callers must supply these to open
encrypted databases).
…rruption

Four .expect() calls in page_cache.rs on try_into() during spillway
rehydration bypassed the I1 poison model with a panic on internal
corruption (wrong-length blob). Replaced with .map_err(|_| DecryptionFailed)
so the typed error propagates and the poison wrapper fires as intended.

wrap_into() in superblock/crypto_header.rs panicked via .expect() if
derive_kek returned an error (e.g. caller passes a zero-length key).
Changed the function signature to return Result<(), CryptoError> and
propagate the error. Callers in transaction/keys.rs map the CryptoError
to InvalidEncryptionKey; test callers use .expect() (panics are fine in
tests). Also replaced the bare state == 1 literal in is_active() with
the new KEY_SLOT_ACTIVE named constant for consistency.
- PageCipher doc comment: clarified that the AEAD cipher is derived from
  the DEK on each seal/open call, not constructed once and reused.
- error.rs encryption_error_classification test: "all four" -> "all six"
  (the loop covers six variants, not four).
- ARCHITECTURE.md envelope description: corrected prefix layout from
  "1-byte algorithm id and 4-byte stride field" (implied 5-byte prefix,
  slots at byte 329) to the actual 8-byte prefix (1-byte algorithm id,
  4-byte stride, 3 reserved bytes; key-slot table begins at byte 332).
…) scoping

tests/encryption_create.rs referenced crypto/superblock/format internals
(CryptoHeader, KeySlot, derive_kek, unwrap_dek, KdfId, ALGO_XCHACHA20POLY1305,
CRYPTO_HEADER_OFFSET, KEY_SLOT_COUNT, KEY_SLOT_SIZE, FORMAT_MAJOR_VERSION_ENCRYPTED)
that are now pub(crate). Moved the whole file to an inline #[cfg(test)] module
at src/transaction/create_tests.rs, rewriting chisel::X imports to crate:: paths.
Every assertion is preserved (create-artifact major-version, slot-0 population,
named-root cleartext-leak check, wrap/unwrap round-trip). Old file deleted.

Kek::from_bytes is now only reachable from tests (derive_kek builds Kek directly),
so it is gated with #[cfg(test)] to avoid dead-code in release builds.

derive_kek now rejects empty key material with BadKeyLength (it previously
documented this but did not enforce it — HKDF/Argon2id silently accept a
zero-length ikm). Refusing an empty caller-supplied key at this trust boundary.
…er fsync poison

- Argon2id known-answer test in src/crypto/mod.rs: pins the exact 32-byte
  output of the derive_kek Argon2id path (cheap params) as a golden regression
  value, so a silent KDF config change (algorithm/version/params/output length)
  is caught. Documented as a golden pin (the RFC 9106 reference vectors use
  secret+AD parameters the derive_kek API does not expose).

- In-memory encrypted round-trip in tests/encryption_roundtrip.rs: opens an
  encrypted in-memory DB via open_in_memory_with_options, writes and reads two
  values, updates one, verifies the other is unchanged. Plus a plaintext
  in-memory baseline so a regression in the in-memory path is distinguishable
  from an encryption-specific failure. Public API only.

- rewrite_crypto_header fsync-failure poison test in src/transaction/keys.rs:
  arms Fault::FailFsync on the rotation path (via add_key) and asserts the
  manager is poisoned and rejects further ops with Poisoned, confirming the I1
  poison-on-fsync-failure wiring covers key rotation, not just data commits.
  Plus a test that add_key with a zero-length raw key returns
  InvalidEncryptionKey rather than panicking.
Resolve the sole conflict in src/page_io.rs: main's commenting pass
(#83/#84) corrected the fsync doc-comment from "twice" to "three times
per commit" (I28 pre-drain flush + data pages + superblock), while this
branch renamed the Memory backing field pages -> bytes. Kept both: the
corrected "three times" comment (still accurate under encryption — the
per-page seal changes the unit size, not the fsync count) and a match
pattern valid for the renamed field (`Backing::Memory { .. }`).

The other three shared files (python/src/errors.rs, src/defrag.rs,
src/page_cache.rs) auto-merged cleanly. Verified on the merged tree:
680 tests across 32 suites pass, clippy clean on lib + chisel-py.
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

🚦 Bench results: PR vs main

✅ No regressions detected

Scenario Mode Δ throughput Worst Δ
document-store chisel-mem +77.3%
document-store chisel-strict +5.2%
document-store redb-strict +3.4%
document-store sqlite-strict +0.5%
mutation-log chisel-mem +4.2%
mutation-log chisel-strict +1.3%
mutation-log redb-strict +0.2%
mutation-log sqlite-strict +0.5%
ycsb-a chisel-mem +8.8%
ycsb-a chisel-strict +4.8%
ycsb-a redb-strict -0.2%
ycsb-a sqlite-strict +14.3%
ycsb-b chisel-mem +51.6%
ycsb-b chisel-strict +6.1%
ycsb-b redb-strict +1.0%
ycsb-b sqlite-strict +0.6%
Per-scenario detail (4 metrics × cells)

document-store

Mode Throughput p50 p95 p99
chisel-mem 18494 ops/s → 32783 ops/s (+77.3%) 14.9 µs → 5.5 µs (-63.4%) 138.7 µs → 68.7 µs (-50.5%) 673.2 µs → 298.8 µs (-55.6%)
chisel-strict 3002 ops/s → 3159 ops/s (+5.2%) 24.7 µs → 13.8 µs (-44.4%) 832.5 µs → 789.1 µs (-5.2%) 3.00 ms → 2.01 ms (-32.9%)
redb-strict 3997 ops/s → 4133 ops/s (+3.4%) 13.3 µs → 12.6 µs (-5.6%) 457.5 µs → 415.9 µs (-9.1%) 1.31 ms → 1.24 ms (-4.9%)
sqlite-strict 5130 ops/s → 5153 ops/s (+0.5%) 21.6 µs → 21.0 µs (-2.4%) 348.4 µs → 342.6 µs (-1.7%) 1.36 ms → 1.29 ms (-5.4%)

mutation-log

Mode Throughput p50 p95 p99
chisel-mem 51891 ops/s → 54050 ops/s (+4.2%) 22.9 µs → 23.2 µs (+1.3%) 28.5 µs → 26.5 µs (-6.9%) 35.2 µs → 35.5 µs (+0.8%)
chisel-strict 1778 ops/s → 1801 ops/s (+1.3%) 294.1 µs → 293.2 µs (-0.3%) 641.1 µs → 609.0 µs (-5.0%) 18.78 ms → 18.79 ms (+0.1%)
redb-strict 1804 ops/s → 1808 ops/s (+0.2%) 145.4 µs → 139.6 µs (-4.0%) 233.2 µs → 215.2 µs (-7.7%) 34.24 ms → 35.30 ms (+3.1%)
sqlite-strict 5059 ops/s → 5083 ops/s (+0.5%) 94.6 µs → 93.5 µs (-1.2%) 264.0 µs → 257.7 µs (-2.4%) 373.4 µs → 346.9 µs (-7.1%)

ycsb-a

Mode Throughput p50 p95 p99
chisel-mem 36266 ops/s → 39468 ops/s (+8.8%) 41.3 µs → 41.8 µs (+1.2%) 66.1 µs → 58.7 µs (-11.2%) 82.7 µs → 75.2 µs (-9.1%)
chisel-strict 2270 ops/s → 2380 ops/s (+4.8%) 250.4 µs → 251.2 µs (+0.3%) 712.9 µs → 649.5 µs (-8.9%) 1.55 ms → 1.09 ms (-29.6%)
redb-strict 2682 ops/s → 2678 ops/s (-0.2%) 113.4 µs → 114.9 µs (+1.3%) 193.8 µs → 200.5 µs (+3.4%) 330.8 µs → 341.6 µs (+3.3%)
sqlite-strict 112870 ops/s → 129060 ops/s (+14.3%) 9.1 µs → 7.7 µs (-15.3%) 11.9 µs → 10.4 µs (-11.9%) 15.2 µs → 15.6 µs (+2.6%)

ycsb-b

Mode Throughput p50 p95 p99
chisel-mem 163923 ops/s → 248456 ops/s (+51.6%) 4.7 µs → 1.7 µs (-64.6%) 43.3 µs → 45.1 µs (+4.2%) 49.7 µs → 49.3 µs (-0.7%)
chisel-strict 21763 ops/s → 23081 ops/s (+6.1%) 7.4 µs → 3.7 µs (-50.5%) 256.3 µs → 249.9 µs (-2.5%) 662.3 µs → 579.3 µs (-12.5%)
redb-strict 27002 ops/s → 27273 ops/s (+1.0%) 3.2 µs → 3.2 µs (+0.6%) 114.3 µs → 115.1 µs (+0.7%) 197.8 µs → 187.5 µs (-5.2%)
sqlite-strict 159839 ops/s → 160811 ops/s (+0.6%) 6.4 µs → 6.4 µs (+0.0%) 8.6 µs → 8.5 µs (-0.9%) 11.0 µs → 10.7 µs (-2.5%)
Generated by chisel-bench-diff at 2026-07-01T16:04:19Z. Compares PR HEAD against main. Never blocks merge — signal, not gate. Thresholds: throughput 5%, p50 5%, p95 10%, p99 10%.

Xof added 2 commits July 1, 2026 08:48
The CI `fmt` gate (cargo fmt --check) never ran on this branch — CI only
triggers on PRs to main, and the branch was CONFLICTING until now, so no
run was ever produced. Several encryption files carried unformatted code
(hex-array literals, wrapped call args, struct literals). Pure rustfmt
reflow; no logic change — full suite still 680 green.
The encryption feature pulled RustCrypto crates whose latest releases moved
to `edition2024` (Rust 1.85+), which broke the msrv CI job (builds -p chisel
on 1.82 — the documented library floor). Two edition-2021 floor pins, using
the same I61 tilde-pin mechanism already applied to clap in bench:

  - zeroize "~1.8": 1.9.0 + its zeroize_derive macro are edition2024. We use
    only Zeroizing<T> (no #[derive(Zeroize)]), so the `derive` feature is
    dropped, removing zeroize_derive from the tree entirely.
  - base64ct "~1.6": a hard transitive dep of argon2 0.5.3; 1.7+ is
    edition2024. We never emit PHC hash strings (we call the raw
    hash_password_into KDF), so the pinned version's code path is unused.

Verified by reproducing the exact CI job locally: `cargo +1.82 build
-p chisel` now Finishes. Full suite 680 green on stable; clippy + fmt clean.
@Xof
Xof merged commit 5bc29cd into main Jul 1, 2026
10 checks passed
@Xof
Xof deleted the feature/on-disk-encryption branch July 1, 2026 16:18
Xof added a commit that referenced this pull request Jul 1, 2026
…#87)

An adversarial commenting pass over the on-disk encryption code (merged in
#85). Comments only — no behavior change; full test suite, clippy, and fmt
stay green.

Wrong facts:
- crypto/mod.rs: derive_kek's `# Errors` said BadKeyLength fires only for
  `kdf == Hkdf` with empty raw bytes; the empty-key check is unconditional
  (before the KDF match) and covers Raw and Passphrase.
- superblock/mod.rs (x2): sealed-superblock comments said sensitive scalars
  16..52 are zeroed, but page_size is cleartext at 48..52 — the zeroed range
  is 16..48.
- python/src/db.rs: key coercion claimed an empty/bad raw key raises
  BadKeyLength via to_py_err; every CryptoError maps to InvalidEncryptionKey
  -> InvalidEncryptionKeyError (there is no BadKeyLength Python error).
- transaction/recovery.rs: "Slot 0 was written last" — it is written first
  (i=0, highest counter superblock_count-1).
- transaction/keys.rs (test): comment said txn_counter=3 after
  fresh_encrypted; it is 2.

Stale:
- page_io.rs: header still said "Two fsyncs per commit"; it is three
  (I28 pre-drain + data + superblock) — the header #84 missed.
- transaction/commit.rs + lifecycle.rs: CommitCtx described as "ten pieces"
  of state; the cipher + crypto_header fields make it twelve.
- python/src/errors.rs: exception-hierarchy comment omitted
  DecryptionFailedError from the FatalError tier.

Three further candidates could not be adversarially verified (transient rate
limit) and were left as-is rather than rewritten on an unverified claim.
Xof added a commit that referenced this pull request Jul 1, 2026
Constructive commenting pass (/comment-run) over the on-disk encryption
surface merged in #85 — the complement to the wrong-comment fixes in #87.
Additive only: ADD/EXTEND, no existing comment text deleted or rewritten.
Comments only; full test suite, clippy, and fmt stay green.

Each captures a non-obvious "we do X because Y" a first-time reader could
not infer from the code:

- crypto/mod.rs: Argon2id's Version::V0x13 and 32-byte output are pinned to
  the on-disk format (like KEK_INFO for HKDF) — changing either silently
  breaks unwrap of every existing Argon2id slot.
- superblock/mod.rs: the four bootstrap fields must stay cleartext because
  they ARE the AAD, and slot selection + AAD derivation run before any DEK
  is available (a sealed body would be a chicken-and-egg deadlock).
- page_cache.rs: the encrypted cold-load DecryptionFailed is fatal/poisoning,
  a peer of ChecksumMismatch, not a retryable operational error.
- page_io.rs: set_stride swallows a seek error to 0 because its signature is
  infallible (bootstrap call site) and 0 makes reads fail closed.
- spillway.rs: spill writes are deliberately not fsynced — content never
  crosses a transaction boundary, so durability would be wasted I/O.

The pass found 0 wrong comments (fixed in #87) and 0 bugs. Six of the eleven
modules reviewed needed nothing added — the encryption code was already
well-commented from its per-task review gates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant