From 17a73a305821e6ec4b6c05ae5abf9529aed20210 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 18:48:53 -0700 Subject: [PATCH 01/42] docs: design spec for on-disk encryption (XChaCha20-Poly1305, envelope keys) --- .../2026-06-29-on-disk-encryption-design.md | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 docs/specs/2026-06-29-on-disk-encryption-design.md diff --git a/docs/specs/2026-06-29-on-disk-encryption-design.md b/docs/specs/2026-06-29-on-disk-encryption-design.md new file mode 100644 index 0000000..3df955a --- /dev/null +++ b/docs/specs/2026-06-29-on-disk-encryption-design.md @@ -0,0 +1,356 @@ +# On-Disk Encryption — Design + +Date: 2026-06-29 +Status: Approved (design), pending implementation plan +Topic: Client-supplied at-rest encryption for the Chisel storage engine + +--- + +## 1. Goal and scope + +Add **authenticated on-disk encryption** to Chisel. The client program supplies an +encryption key (raw key bytes or a passphrase) when opening the database. With a key, +every byte Chisel writes to durable storage is encrypted and integrity-protected; +without a key, the database is plaintext exactly as today (encryption is opt-in per +database, chosen at create time). + +In scope: + +- One AEAD algorithm balancing speed and security: **XChaCha20-Poly1305**. +- **Credential (key) rotation** that is cheap (O(1), no bulk re-encryption). +- Confidentiality and cryptographic tamper-detection for: main-file pages + (data / index / freemap / handle-table / overflow / membership), the **spillway** + overflow file, and the **sensitive superblock fields** (including user-chosen + `named_roots` names). + +Out of scope (documented as known boundaries, see §9): + +- Full **Data Encryption Key (DEK) rotation** / bulk re-encryption (deferred; a heavy + whole-file operation reserved for "the DEK itself is compromised"). +- **Rollback / replay** resistance against an attacker who can substitute a wholly + older, validly-signed database image (needs an external trust anchor; impossible for + a self-contained file). + +--- + +## 2. Decisions (and why) + +| Decision | Choice | Rationale | +|---|---|---| +| Integrity model | **Authenticated (AEAD)**, not length-preserving (XTS) | Cryptographic tamper-detection, not just confidentiality. Cheap to adopt now: no production databases exist, so the one-time format change is free. Upgrades integrity from the non-cryptographic XXH3 (forgeable) to an unforgeable Poly1305 tag. | +| Cipher | **XChaCha20-Poly1305** (extended 192-bit nonce) | Constant-time in portable software (no AES-NI dependency) — correct default for an embedded library that runs on unknown hardware. The 192-bit nonce lets us use **random** nonces safely (see §2.1), eliminating a crash-reuse hazard that the 96-bit variant would introduce. Crypto throughput is far below fsync latency, so AES-GCM's hardware edge is irrelevant here. | +| Key management | **Envelope**: random per-DB **DEK** wrapped by a **KEK** derived from the client key | Makes credential rotation O(1) (re-wrap the DEK) instead of O(database size). | +| Key derivation | **HKDF-SHA256** for raw keys; **Argon2id** for passphrases | Right tool per input entropy: HKDF is fast and correct for high-entropy keys; Argon2id is memory-hard to resist brute-forcing low-entropy passphrases. The chosen KDF is recorded per key-slot. | +| Page format | **Larger on-disk stride (8232 B)**; logical page stays **8192 B** | Smallest blast radius: the page cache, freemap, data pages, handle table, and the entire transaction layer keep producing/consuming byte-identical 8192-byte pages. Only the I/O stride and the seal/open transform change. Cost: 0.49% larger encrypted files. Alternative (shrink logical content to fit a 40-byte trailer inside 8192) was rejected — it would make every geometry constant encryption-dependent, an invasive and bug-prone refactor of the engine's core page math. | +| Rotation scope (v1) | **Credential rotation only** (key-slot re-wrap) | Covers the normal meaning of "rotate my key." Bulk DEK rotation deferred. | + +### 2.1 The nonce hazard that drove the cipher choice + +A naïve design derives each page's nonce deterministically from `(page_id, counter)`. +That is **unsafe** under Chisel's shadow-paging + freemap page reuse: + +- Shadow paging discards a crashed transaction's writes and returns its `page_id`s to + the freemap. The durable superblock's counters did **not** advance (the commit never + finished). +- After the crash, Chisel legitimately writes **different plaintext to the same + `page_id`** while any persisted counter still sits at its pre-crash value. +- Result: identical `(key, nonce)` over different plaintext — catastrophic keystream + reuse for any Poly1305/GCM construction. AAD does not help; it is the **nonce + key** + pair that must never repeat. + +Using a deterministic nonce safely would require a persisted, crash-monotonic counter +with gap reservation — extra moving parts and a subtle invariant. Instead we use +**XChaCha20-Poly1305 with a fresh random 192-bit nonce per page write**. Collision +probability is negligible (< 2⁻³² well past 2⁸⁰ writes), there is nothing to persist, +and the crash-reuse class of bugs cannot occur. The nonce is stored alongside the +ciphertext (it is not secret). + +--- + +## 3. Cryptographic architecture + +### 3.1 Key hierarchy (envelope) + +``` +client key ──KDF(per-slot salt)──▶ KEK ──unwraps──▶ DEK ──seals──▶ pages + superblock body + raw bytes → HKDF-SHA256 (256-bit, (XChaCha20-Poly1305, + passphrase → Argon2id random, AAD = page_id) + stable for DB life) +``` + +- **DEK** (Data Encryption Key): 256-bit, generated once with `OsRng` at create time. + It seals every page and the sensitive superblock fields. It never changes during + normal operation; it is held in memory (zeroizing) for the open session only. +- **KEK** (Key Encryption Key): 256-bit, derived per-open from the client key + the + matching key-slot's salt/params. It only ever wraps/unwraps the DEK. +- A successful unwrap (the wrap's Poly1305 tag verifies) **is** the proof that the + client key is correct — there is no separate password verifier to leak. + +### 3.2 Key-slot table + +Eight fixed slots live in the superblock's plaintext reserved region (§5). Each slot: + +| Field | Size | Notes | +|---|---|---| +| `state` | 1 | 0 = empty, 1 = active | +| `kdf_id` | 1 | 1 = HKDF-SHA256, 2 = Argon2id | +| (reserved) | 2 | alignment / future | +| `argon2_m_cost` | 4 | KiB; 0 for HKDF | +| `argon2_t_cost` | 4 | iterations; 0 for HKDF | +| `argon2_p_cost` | 4 | lanes; 0 for HKDF | +| `salt` | 16 | per-slot, random | +| `wrap_nonce` | 24 | XChaCha nonce for the DEK wrap (random) | +| `wrapped_dek` | 32 | DEK ciphertext | +| `wrap_tag` | 16 | Poly1305 tag over the wrap | +| (padding) | — | pad slot to a fixed 128 bytes | + +8 slots × 128 B = 1024 B, comfortably inside the ~7860 free reserved bytes. +The wrap's AAD binds the slot metadata (`kdf_id`, `salt`, Argon2 params) so an +attacker cannot tamper a slot's parameters to force mis-derivation. + +### 3.3 Rotation operations (all are ordinary superblock commits) + +- `add_key(existing_key, new_key)`: derive a KEK from `new_key`, wrap the **same** DEK + into a free slot. Lets a new credential go live before the old one is retired. +- `rotate_key(old_key, new_key)`: `add_key` then clear the old slot. +- `remove_key(slot)`: clear a slot (refuse to remove the last active slot). + +Each writes a new superblock via the existing A/B + fsync protocol, so rotation is +crash-safe and O(1) — no page is re-encrypted. + +### 3.4 Dependencies (all pure-Rust, well-vetted) + +`chacha20poly1305` (XChaCha20-Poly1305), `argon2`, `hkdf` + `sha2`, `zeroize`, +`rand_core`/`getrandom`. Rolling our own crypto is explicitly forbidden; only vetted +primitives are used. + +--- + +## 4. Page format and the I/O seam + +### 4.1 On-disk encrypted page + +For an encrypted database, each page occupies a stride of **8232 bytes**: + +``` + offset 0 8192 8208 8232 + ┌──────────────────────────────────┬───────────┬──────────────┐ + │ ciphertext (8192) │ tag (16) │ nonce (24) │ + └──────────────────────────────────┴───────────┴──────────────┘ + = XChaCha20-Poly1305 seal of the Poly1305 random per write + full 8192-byte plaintext page image tag (not secret) +``` + +- The plaintext input is the **entire normal 8192-byte page image** — header, body, + slots, freemap bitmaps, and the existing XXH3 checksum — produced byte-for-byte as + today by the layers above. Encryption is a transform strictly below the page + abstraction. +- `AAD = page_id` gives **anti-relocation**: a sealed page authenticates only at its + own `page_id`; an attacker cannot move a valid ciphertext to a different slot. +- The on-disk offset is `page_id × 8232` (vs `page_id × 8192` for plaintext DBs). + Verified that the offset is computed in exactly three places + (`page_io::read_page` @256, `write_page` @296, `set_page_count` @418) plus the + spillway's own offset math — a small, contained change. +- The inner XXH3 checksum is now redundant with the AEAD tag but is **kept** so the + upper layers stay untouched; it serves as a cheap inner sanity check after `open`. + +### 4.2 The seam and seal-once invariant + +``` +WRITE (flush): cache plaintext[8192] ──seal(page_id)──▶ on-disk blob[8232] ──▶ main file @ page_id×8232 +EVICT (spill): cache plaintext[8192] ──seal(page_id)──▶ on-disk blob[8232] ──▶ spillway slot +DRAIN: spillway blob[8232] ───────copy───────────────────────────────▶ main file @ page_id×8232 +READ: main file blob[8232] ──open(page_id)──▶ plaintext[8192] ──▶ cache +``` + +**Seal-once:** a page is sealed exactly once when it first leaves the plaintext cache — +whether to the main file or the spillway. Draining the spillway to the main file is a +**byte copy** of the already-sealed blob, never a re-seal, because both use the same +`AAD = page_id`. This avoids a crypto round-trip on drain and keeps a single ciphertext +per write. + +**Layering:** the `PageCipher` (holds the DEK) lives in the page-cache layer, which +already owns both `page_io` and the spillway and orchestrates flush / spill / drain. +`page_io` becomes **stride-aware but crypto-agnostic**: it reads/writes the on-disk +page unit (8232 encrypted, 8192 plaintext) at the correct offset; the seal/open +decision lives one layer up. Spillway slots widen to hold the 8232-byte sealed blob +(`SLOT_HEADER_SIZE 16 + 8232`); the spillway's `slot_checksum` is computed over the +sealed bytes and continues to protect spillway round-trips. + +--- + +## 5. Superblock handling + +The superblock (pages `0..superblock_count`) is **not** whole-page sealed — it must +bootstrap the key material. Page 0 sits at offset 0 regardless of stride, so it is +always readable first to learn `encrypted? / algorithm / stride / key-slots`, after +which the remaining slots are read at the correct stride. + +Within the superblock image: + +- **Plaintext (bootstrap, unchanged offsets):** `magic` (0..4), `format_version` + (4..8), `txn_counter` (8..16, needed to select the active slot), `page_size` + (48..52), `superblock_count` (308..312). +- **New plaintext crypto-header**, placed in the reserved region (from offset 324, + ~7860 free bytes): an encryption-enabled flag, algorithm id, on-disk stride, and the + 8-slot key-slot table (§3.2). +- **Encrypted under the DEK** (a `nonce ‖ tag ‖ ciphertext` sub-blob also in the + reserved region): the sensitive body — root pointers (`root_handle_table_page`, + `root_freemap_page`, `root_membership_index_page`), `total_pages`, `next_handle`, + `freemap_depth`, and **`named_roots`** (user-chosen UTF-8 names — real user data that + must not leak). The body's AAD binds it to this superblock's identity + (`magic`, `format_version`, `txn_counter`, `superblock_count`) to prevent splicing. + +The plaintext portion keeps its existing XXH3 checksum so torn-write detection in +`Superblock::select()` still works on the bootstrap fields; the encrypted body is +additionally protected by its own AEAD tag. + +**Open flow:** read page 0 plaintext → if encrypted, derive a KEK from the client key +and each active slot's salt/params, try to unwrap the DEK (first success wins; ≤ 8 +attempts) → decrypt the body sub-blob → validate `total_pages` against file size → +proceed. To stay robust across a key-rotation that landed between the A/B slots, the +key-slot tables of all readable superblock slots are considered when unwrapping, while +roots come from the highest-`txn_counter` slot. (`stride`/`algorithm` are stable across +rotations, so reading them from either slot is safe.) + +--- + +## 6. Public API and key lifetime + +### 6.1 Rust + +```rust +pub enum Key { + Raw(Zeroizing>), // high-entropy key → HKDF-SHA256 + Passphrase(Zeroizing), // human passphrase → Argon2id +} + +// Options gains (it is already #[non_exhaustive], lib.rs:131): +pub struct Options { + // ...existing... + pub encryption_key: Option, + pub argon2_params: Option, // create-time default for passphrase slots +} + +// New methods on Chisel: +fn add_key(&mut self, existing: &Key, new: &Key) -> Result<()>; +fn rotate_key(&mut self, old: &Key, new: &Key) -> Result<()>; +fn remove_key(&mut self, slot_or_key: ...) -> Result<()>; +``` + +The engine (`TransactionManager`) holds the unwrapped DEK in a zeroizing wrapper for +the session and wipes it on drop. The client key and derived KEK are zeroized +immediately after use. No key material is ever written to disk except the +KEK-**wrapped** DEK in the key-slot table. + +### 6.2 Python (pyo3) + +`open(..., encryption_key=...)`: `bytes` → `Key::Raw`, `str` → `Key::Passphrase`. +Plus `add_key` / `rotate_key` / `remove_key` methods mirroring the Rust API. + +--- + +## 7. Errors + +Slots into the existing operational-vs-fatal (I1 poison) model: + +- **Operational (retryable — must NOT poison the engine):** + - `NoEncryptionKey` — opening an encrypted DB without a key. + - `InvalidEncryptionKey` — supplied key unwraps no slot (wrong key/passphrase). + - `EncryptionNotSupported` / `UnexpectedKey` — key supplied for a plaintext DB, or + mismatch. +- **Fatal (poison — corruption/tamper after a valid open):** + - `DecryptionFailed { page_id }` — a page's AEAD tag fails verification after the DB + was opened with a valid key. This means on-disk tampering or corruption. +- **Format gate:** an old, encryption-unaware Chisel binary cannot open an encrypted + DB — the **MAJOR format-version bump** (1 → 2) yields `UnsupportedFormatVersion`, + preventing it from misreading ciphertext as plaintext. + +--- + +## 8. Format versioning + +- File-level **MAJOR** bump `1 → 2` for encrypted databases (hard-rejects old binaries). + Plaintext databases are unaffected and continue at the current version. Because no + production databases exist, this bump is free; the exact post-1.0 numbering is settled + at release. +- No per-page (I31) format change is required: the logical 8192-byte page image is + unchanged; encryption lives entirely below it. + +--- + +## 9. Threat model — what this does and does not protect + +Provided (under the AEAD model): + +- **Confidentiality** of all user data and sensitive metadata at rest. +- **Tamper-detection**: any modification of a page or the superblock body is detected + (Poly1305/AEAD), surfaced as `DecryptionFailed` (fatal). +- **Anti-relocation**: `AAD = page_id` prevents moving a valid ciphertext to another + slot. + +Not provided (documented boundaries): + +- **Rollback / replay resistance**: an attacker with file access who substitutes a + wholly older, validly-signed database image (or an older valid A/B superblock slot) + cannot be detected by self-contained authentication. Defeating this requires an + external monotonic trust anchor (e.g., TPM), which is out of scope for a file-based + embedded store. +- **In-memory protection**: the page cache and the DEK are plaintext in process memory + during an open session (mitigated by zeroize-on-drop, not by encryption). +- **Traffic-analysis / size**: file size, page count, and access patterns are not + hidden. + +--- + +## 10. Testing + +- **Unit:** KDF known-answer vectors (HKDF, Argon2id); AEAD seal/open round-trip; DEK + wrap/unwrap; wrong-key rejection; byte-flip in ciphertext → `DecryptionFailed`; + relocation (move a sealed page to a wrong `page_id`) → auth failure; key-slot codec + round-trip; zeroization (where testable). +- **Integration:** create encrypted DB → write → close → reopen with key → read back; + reopen with wrong key → `InvalidEncryptionKey`; reopen without key → `NoEncryptionKey`; + open encrypted DB with old format → `UnsupportedFormatVersion`; forced spillway + spill+drain under encryption; `add_key` / `rotate_key` (old fails, new works) / + `remove_key`; crash-during-encrypted-commit recovery picks the prior valid superblock. +- **Property/fuzz:** random page contents seal/open round-trip; any corrupted ciphertext + is always detected. +- **Benchmarks** (report-only in CI, per project policy): encrypted vs plaintext + throughput, confirming encryption overhead is small relative to fsync-dominated + commit latency; KDF cost (Argon2id) on open. +- Coverage aimed near 100%, consistent with the project standard. + +--- + +## 11. Phasing (each layer completed before the next) + +1. **Crypto core** — a standalone module (`src/crypto/` or `src/encryption/`): + `PageCipher` (seal/open), KDF (HKDF + Argon2id), DEK wrap/unwrap, key-slot codec, + zeroizing `Key`/DEK types. Fully unit-tested in isolation, no engine coupling. +2. **Superblock crypto-header + encrypted body** — extend serialize/deserialize; the + create-new and open-existing key flows; the operational error variants. +3. **Page I/O encryption** — stride-aware `page_io`; `PageCipher` wired into the + page-cache flush / spill / drain paths (seal-once invariant); spillway slot widening. +4. **Public API + errors + Python** — `Options.encryption_key`, the `Key` enum, error + variants, pyo3 kwargs. +5. **Key-management API** — `add_key` / `rotate_key` / `remove_key` (Rust + Python). +6. **Docs + ADR + format-version bump** — ARCHITECTURE.md, ADR graph (codebase-memory), + the MAJOR version change, and an ISSUES.md entry for deferred bulk DEK rotation. + +--- + +## 12. Affected code (verified) + +| Area | File(s) | Change | +|---|---|---| +| I/O stride + raw on-disk unit | `src/page_io.rs` (offsets @256/@296/@418) | stride = 8232 when encrypted; read/write the on-disk page unit | +| Seal/open orchestration | `src/page_cache.rs` (flush @~420, spill/drain) | hold `PageCipher`; seal-once on flush/evict; copy on drain | +| Spillway | `src/spillway.rs` (`SLOT_SIZE` @43) | widen slot to carry the 8232-byte sealed blob | +| Superblock | `src/superblock.rs` (`serialize` @245, reserved ≥324) | crypto-header + encrypted body sub-blob; key-slot codec | +| Open/create/rotate | `src/lib.rs` (`Options` @131, `open` @310), `src/transaction/` (recovery, commit, mod) | key flow, DEK in `TransactionManager`, rotation ops | +| Errors | `src/error.rs` | operational + fatal encryption variants | +| Format version | `src/page.rs` (`FORMAT_MAJOR_VERSION` @113) | MAJOR bump for encrypted DBs | +| Python | `python/src/db.rs` | `encryption_key` kwarg + rotation methods | +| New module | `src/crypto/` (new) | all primitives, KDF, wrap, key-slot codec | +| Deps | `Cargo.toml` | chacha20poly1305, argon2, hkdf, sha2, zeroize, rand_core/getrandom | From 8564e38908cd32d0477f90db6a86192d4440a8e0 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 19:19:25 -0700 Subject: [PATCH 02/42] docs: implementation plan for on-disk encryption (6 phases, 29 tasks) --- docs/plans/2026-06-29-on-disk-encryption.md | 4555 +++++++++++++++++++ 1 file changed, 4555 insertions(+) create mode 100644 docs/plans/2026-06-29-on-disk-encryption.md diff --git a/docs/plans/2026-06-29-on-disk-encryption.md b/docs/plans/2026-06-29-on-disk-encryption.md new file mode 100644 index 0000000..b2298b3 --- /dev/null +++ b/docs/plans/2026-06-29-on-disk-encryption.md @@ -0,0 +1,4555 @@ +# On-Disk Encryption Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add client-supplied, authenticated on-disk encryption (XChaCha20-Poly1305 with envelope key management) to the Chisel storage engine, with O(1) credential rotation. + +**Architecture:** Encryption is a transform at the page-I/O boundary. The page cache and every layer above keep producing byte-identical 8192-byte pages; a `PageCipher` holding a random per-database Data Encryption Key (DEK) seals each page into an 8232-byte on-disk blob (`ciphertext ‖ tag ‖ nonce`) with `AAD = page_id` (anti-relocation). The DEK is wrapped under a Key Encryption Key (KEK) derived from the client's key — HKDF-SHA256 for raw keys, Argon2id for passphrases — and stored in an 8-slot table in the superblock's plaintext reserved region, so rotating a credential is an O(1) re-wrap, not a re-encryption. The sensitive superblock fields (including `named_roots`) are DEK-sealed; the crypto-header stays plaintext to bootstrap. Encrypted databases stamp a MAJOR format-version bump so encryption-unaware binaries refuse to open them. + +**Tech Stack:** Rust; RustCrypto crates (`chacha20poly1305`, `argon2`, `hkdf` + `sha2`, `zeroize`, `getrandom`); pyo3 for the Python bindings. + +## Global Constraints + +_Every task's requirements implicitly include this section._ + +- **Spec (authoritative):** `docs/specs/2026-06-29-on-disk-encryption-design.md`. +- **Branch:** `feature/on-disk-encryption` (off `main`). After a PR exists, open a NEW PR for follow-up work — never amend/force-push a merged branch. +- **AEAD:** XChaCha20-Poly1305, a fresh **random 192-bit nonce per page write**, `AAD = page_id` (u64 little-endian). The `(nonce, key)` pair must never repeat — random nonces are the crash-safe construction (do not derive nonces from page state). +- **Page geometry:** logical page = **8192 bytes (unchanged)**; encrypted on-disk stride `ENC_PAGE_SIZE = 8232`. Plaintext databases are untouched (stride 8192, XXH3 checksum). +- **KDF:** raw key → HKDF-SHA256(salt, `info = b"chisel-kek-v1"`); passphrase → Argon2id (default `m_cost = 19456` KiB, `t_cost = 2`, `p_cost = 1`). +- **Key material:** `Key`, `Dek`, `Kek` are zeroizing types; key material is never written to disk except the **KEK-wrapped** DEK in the key-slot table. +- **Error model:** `NoEncryptionKey` / `InvalidEncryptionKey` / `EncryptionNotSupported` are **operational** (must NOT poison the engine — a wrong password is retryable); `DecryptionFailed { page_id }` is **fatal** (`is_fatal() == true`). +- **Format gate:** MAJOR version bump `1 → 2` for encrypted DBs only; plaintext DBs stay MAJOR 1. +- **Testing:** run plain `cargo test` (NOT `--lib`, so integration tests run) and keep CI green before every commit/push. Benchmarks are report-only in CI. +- **No Claude/AI references** in commit messages, code comments, or docs. +- **Crypto discipline:** vetted RustCrypto primitives only — never hand-roll. Prove KDF/AEAD wiring with known-answer (HKDF RFC 5869) or round-trip tests. + +## File Structure + +| File | New/Modify | Responsibility | +|---|---|---| +| `src/crypto/mod.rs` | new | Standalone crypto core: `PageCipher` (page + body seal/open), KDF (HKDF + Argon2id), DEK wrap/unwrap, zeroizing `Key`/`Dek`/`Kek`, `CryptoError`. No engine coupling. | +| `Cargo.toml` | modify | Crypto dependencies. | +| `src/superblock.rs` | modify | `CryptoHeader` + 8-slot `KeySlot` codec in the reserved region; DEK-sealed sensitive-body sub-blob; `Superblock.encryption`. | +| `src/transaction/{recovery,commit,mod}.rs` | modify | Create/open key flow; DEK held (zeroizing) in `TransactionManager`; rotation commits. | +| `src/page_io.rs` | modify | Stride-aware `read_page`/`write_page`/`set_page_count` moving the on-disk page unit (8232 enc / 8192 plain); crypto-agnostic. | +| `src/page_cache.rs` | modify | Holds `Option`; seal-once on flush/evict, copy-on-drain, open-on-read. | +| `src/spillway.rs` | modify | Widen slot to carry the 8232-byte sealed blob. | +| `src/lib.rs` | modify | `Options.encryption_key` + `argon2_params`; public `Key` surface; wire `open()`; `add_key`/`rotate_key`/`remove_key`. | +| `src/error.rs` | modify | Encryption error variants + `is_fatal()` classification. | +| `src/page.rs` | modify | `FORMAT_MAJOR_VERSION` gate for encrypted DBs. | +| `python/src/db.rs` | modify | `encryption_key` kwarg; `add_key`/`rotate_key`/`remove_key`; error mapping. | +| `ARCHITECTURE.md`, `ISSUES.md` | modify | Encryption section; deferred bulk-DEK-rotation record. | + +## Phase order & dependencies + +Phases are sequential: **1** (crypto core) underpins all; **2** (superblock/key flow) and **3** (page I/O) both consume Phase 1; **4** (API/errors/Python) exposes them; **5** (key management) builds on 1/2/4; **6** (docs/version/ADR) closes out. Within a phase, tasks are ordered. + +**Execution-order exception:** Task 4.1 (adding the encryption error variants to `ChiselError`) is dependency-free (`Consumes: nothing`) and **must be implemented first — before Phase 2** — because Phases 2, 3, and 5 return these variants. It is filed under Phase 4 for cohesion with the public-API surface; a subagent-driven runner should dispatch it ahead of Phase 1 Task 1.2, and a linear runner should jump to it before starting Phase 2. With it done first, no placeholder error variant is ever needed. + +--- + +## Phase 1: Crypto core module + +This phase builds `src/crypto/` as a standalone, fully unit-tested module with zero engine coupling. It produces every Phase-1 contract interface verbatim. Each task ends in an independently testable deliverable; tasks build up one file (`src/crypto/mod.rs`) plus the two registration edits (`Cargo.toml`, `src/lib.rs`). + +ponytail note: one file, not a directory tree of `kdf.rs`/`aead.rs`/`wrap.rs`. The whole module is ~400 lines; splitting it into five files for one consumer is premature. Split later if it crosses 2000 lines (it won't). + +--- + +### Task 1.1: Add crypto dependencies and an empty registered module + +**Files:** +- Modify: `Cargo.toml:66` (after the `rustc-hash = "2"` runtime dep, line 66) +- Create: `src/crypto/mod.rs` +- Modify: `src/lib.rs:41` (insert `mod crypto;` adjacent to the other `mod` lines, e.g. right after `pub(crate) mod error;`) + +**Interfaces:** +- Consumes: nothing +- Produces: a compilable, registered `crypto` module; the six crypto crates resolved in `Cargo.lock`. + +- [ ] **Step 1: Write the failing test** +This task's deliverable is "the workspace builds with the new deps and module present." The runnable check is `cargo build`. Create the module file with a single trivial item so the `mod crypto;` line has something to point at, and a compile-time assertion that one dep links: +```rust +// src/crypto/mod.rs — Crypto core (layer 1, no engine coupling). +// +// Standalone at-rest encryption primitives for Chisel: the XChaCha20-Poly1305 +// PageCipher (whole-page + variable-length body seal/open), the envelope KDF +// (HKDF-SHA256 for raw keys, Argon2id for passphrases), DEK wrap/unwrap, and +// the zeroizing key types. Nothing here touches page_io, the cache, or the +// superblock — those layers consume this module in later phases. See +// docs/specs/2026-06-29-on-disk-encryption-design.md §3. +// +// All randomness is OS-sourced (getrandom). Rolling our own crypto is +// forbidden; only the vetted RustCrypto primitives are used. + +// Smoke check that the chacha20poly1305 dep is linked and its key length is +// the 32 bytes the envelope assumes. Replaced by real tests in later tasks. +#[cfg(test)] +mod tests { + #[test] + fn deps_link() { + use chacha20poly1305::KeySizeUser; + use chacha20poly1305::XChaCha20Poly1305; + assert_eq!( + ::key_size(), + 32, + "XChaCha20-Poly1305 key must be 32 bytes" + ); + } +} +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo build` Expected: FAIL — `error[E0432]: unresolved import` for `chacha20poly1305` (dep not added yet) and/or `file not found for module crypto` until `mod crypto;` is registered. + +- [ ] **Step 3: Implement** +Add to `Cargo.toml` immediately after line 66 (`rustc-hash = "2"`), inside the existing `[dependencies]` block: +```toml +# On-disk encryption (spec 2026-06-29). All pure-Rust, well-vetted RustCrypto +# primitives — rolling our own crypto is forbidden. These reach the published +# crate's dependency tree only when a DB is opened with an encryption key, but +# they are unconditional deps (the seal/open code is always compiled). Versions +# pinned to the audited RustCrypto generation current as of 2026-06. +chacha20poly1305 = "0.10" # XChaCha20-Poly1305 AEAD (192-bit nonce) +argon2 = "0.5" # Argon2id passphrase KDF (memory-hard) +hkdf = "0.12" # HKDF-SHA256 raw-key KDF +sha2 = "0.10" # SHA-256 for HKDF +zeroize = { version = "1", features = ["derive"] } # wipe key material on drop +getrandom = "0.2" # OS RNG for DEK / nonce / salt generation +``` +Register the module in `src/lib.rs` adjacent to the other declarations (after `pub(crate) mod error;` at line 41): +```rust +pub(crate) mod crypto; +``` +(The `src/crypto/mod.rs` file from Step 1 already exists.) + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test deps_link` Expected: PASS (and `cargo build` succeeds, resolving the six new crates into `Cargo.lock`). + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "build: add RustCrypto deps and register crypto core module" +``` + +--- + +### Task 1.2: Key types, KdfId, Argon2Params, CryptoError, and OS randomness + +**Files:** +- Modify: `src/crypto/mod.rs` (replace the placeholder `tests` module; add the public types and RNG helpers) +- Test: `#[cfg(test)] mod tests` in `src/crypto/mod.rs` + +**Interfaces:** +- Consumes: nothing +- Produces: +```rust +pub const ENC_PAGE_SIZE: usize = 8232; +pub const NONCE_LEN: usize = 24; +pub const TAG_LEN: usize = 16; +pub const DEK_LEN: usize = 32; +pub const SALT_LEN: usize = 16; +pub enum Key { Raw(zeroize::Zeroizing>), Passphrase(zeroize::Zeroizing) } // #[derive(Clone)] +pub struct Dek(zeroize::Zeroizing<[u8; DEK_LEN]>); +pub struct Kek(zeroize::Zeroizing<[u8; 32]>); +pub enum KdfId { Hkdf = 1, Argon2id = 2 } // #[derive(Clone, Copy, PartialEq)] +pub struct Argon2Params { pub m_cost: u32, pub t_cost: u32, pub p_cost: u32 } // #[derive(Clone, Copy)] + Default +pub enum CryptoError { Auth, Kdf, BadKeyLength } // #[derive(Debug, PartialEq)] +pub fn random_dek() -> Dek; +pub fn random_array() -> [u8; N]; +``` + +- [ ] **Step 1: Write the failing test** +Replace the placeholder `tests` module in `src/crypto/mod.rs` with: +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constants_match_spec() { + // On-disk encrypted stride = 8192 ciphertext + 16 tag + 24 nonce. + assert_eq!(ENC_PAGE_SIZE, 8232); + assert_eq!(NONCE_LEN, 24); + assert_eq!(TAG_LEN, 16); + assert_eq!(DEK_LEN, 32); + assert_eq!(SALT_LEN, 16); + assert_eq!(ENC_PAGE_SIZE, 8192 + TAG_LEN + NONCE_LEN); + } + + #[test] + fn argon2_params_default_is_owasp() { + let p = Argon2Params::default(); + assert_eq!(p.m_cost, 19456); // 19 MiB + assert_eq!(p.t_cost, 2); + assert_eq!(p.p_cost, 1); + } + + #[test] + fn kdf_id_discriminants_are_wire_stable() { + // These ints are written into key-slots on disk; pin them. + assert_eq!(KdfId::Hkdf as u8, 1); + assert_eq!(KdfId::Argon2id as u8, 2); + assert_ne!(KdfId::Hkdf, KdfId::Argon2id); + } + + #[test] + fn random_array_is_os_filled_and_distinct() { + let a: [u8; 32] = random_array(); + let b: [u8; 32] = random_array(); + // Astronomically unlikely to collide; all-zero would mean RNG silent-failed. + assert_ne!(a, b); + assert_ne!(a, [0u8; 32]); + } + + #[test] + fn random_dek_differs_each_call() { + let d1 = random_dek(); + let d2 = random_dek(); + assert_ne!(d1.as_bytes(), d2.as_bytes()); + } + + #[test] + fn crypto_error_is_comparable() { + assert_eq!(CryptoError::Auth, CryptoError::Auth); + assert_ne!(CryptoError::Auth, CryptoError::Kdf); + } +} +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test --package chisel crypto::tests` Expected: FAIL — `cannot find type Argon2Params`, `random_dek`, etc. (not yet defined). + +- [ ] **Step 3: Implement** +Insert above the `#[cfg(test)] mod tests` block in `src/crypto/mod.rs`: +```rust +use zeroize::Zeroizing; + +/// On-disk stride of one encrypted page: 8192 ciphertext + 16 tag + 24 nonce. +/// The logical page stays 8192 (spec §4.1); only the I/O unit grows. +pub const ENC_PAGE_SIZE: usize = 8232; +/// XChaCha20 nonce length (192 bits). The extended nonce is what makes random +/// per-write nonces safe under shadow-paging page reuse (spec §2.1). +pub const NONCE_LEN: usize = 24; +/// Poly1305 authentication tag length. +pub const TAG_LEN: usize = 16; +/// Data Encryption Key length (256-bit). +pub const DEK_LEN: usize = 32; +/// Per-key-slot KDF salt length. +pub const SALT_LEN: usize = 16; + +/// Client-supplied encryption credential. `Raw` is high-entropy key bytes +/// (derived via HKDF); `Passphrase` is a human secret (derived via Argon2id). +/// Both are zeroized on drop. `Clone` is needed because `Options` is consumed +/// by `open` while rotation APIs may also hold a key. +#[derive(Clone)] +pub enum Key { + Raw(Zeroizing>), + Passphrase(Zeroizing), +} + +/// The Data Encryption Key: seals every page and the superblock body. Generated +/// once at create time, held for the open session only, wiped on drop. Never +/// written to disk except KEK-wrapped in a key-slot. +pub struct Dek(Zeroizing<[u8; DEK_LEN]>); + +impl Dek { + /// Construct from raw bytes (used by unwrap_dek). Kept crate-internal-ish via + /// module visibility; later phases hold a Dek but do not fabricate one. + pub fn from_bytes(bytes: [u8; DEK_LEN]) -> Self { + Dek(Zeroizing::new(bytes)) + } + /// Borrow the raw key bytes. Callers must not copy these into a non-zeroizing + /// buffer that outlives the operation. + pub fn as_bytes(&self) -> &[u8; DEK_LEN] { + &self.0 + } +} + +impl Clone for Dek { + fn clone(&self) -> Self { + Dek(Zeroizing::new(*self.0)) + } +} + +/// The Key Encryption Key: derived per-open from the client key + a slot's +/// salt/params. Only ever wraps/unwraps the DEK; transient, wiped on drop. +pub struct Kek(Zeroizing<[u8; 32]>); + +impl Kek { + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Kek(Zeroizing::new(bytes)) + } + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// KDF selector recorded per key-slot. The integer discriminants are part of +/// the on-disk format (written into the slot's `kdf_id` byte) — do not renumber. +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum KdfId { + Hkdf = 1, + Argon2id = 2, +} + +/// Argon2id cost parameters. Stored per-slot so a slot can be re-derived +/// regardless of the binary's current defaults. +#[derive(Clone, Copy, Debug)] +pub struct Argon2Params { + pub m_cost: u32, // KiB of memory + pub t_cost: u32, // iterations + pub p_cost: u32, // lanes +} + +impl Default for Argon2Params { + /// OWASP-recommended Argon2id baseline (19 MiB, 2 iterations, 1 lane). + fn default() -> Self { + Argon2Params { + m_cost: 19456, + t_cost: 2, + p_cost: 1, + } + } +} + +/// Failures internal to the crypto layer. The engine layer maps these onto +/// ChiselError (Auth → InvalidEncryptionKey/DecryptionFailed depending on site; +/// Kdf/BadKeyLength → operational key errors). PartialEq for ergonomic tests. +#[derive(Debug, PartialEq)] +pub enum CryptoError { + /// AEAD tag verification failed (wrong key, tampered ciphertext, wrong AAD). + Auth, + /// A key-derivation primitive rejected its parameters. + Kdf, + /// A raw key was not the length the KDF requires. + BadKeyLength, +} + +/// Fill an N-byte array from the OS CSPRNG. Panics if the OS RNG is unavailable, +/// which on a supported platform indicates a broken system — there is no safe +/// fallback for key material, so failing loud is correct. +pub fn random_array() -> [u8; N] { + let mut b = [0u8; N]; + getrandom::getrandom(&mut b).expect("OS RNG unavailable"); + b +} + +/// Generate a fresh random DEK from the OS CSPRNG. +pub fn random_dek() -> Dek { + Dek::from_bytes(random_array::()) +} +``` +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test --package chisel crypto::tests` Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(crypto): key types, KdfId, Argon2Params, CryptoError, OS randomness" +``` + +--- + +### Task 1.3: KEK derivation (HKDF for raw keys, Argon2id for passphrases) + +**Files:** +- Modify: `src/crypto/mod.rs` (add `derive_kek`; extend the `tests` module) +- Test: `#[cfg(test)] mod tests` in `src/crypto/mod.rs` + +**Interfaces:** +- Consumes: `Key`, `KdfId`, `Argon2Params`, `Kek`, `CryptoError`, `SALT_LEN` (Task 1.2) +- Produces: +```rust +pub fn derive_kek(key: &Key, kdf: KdfId, salt: &[u8; SALT_LEN], params: &Argon2Params) -> Result; +``` + +- [ ] **Step 1: Write the failing test** +Add to the `tests` module in `src/crypto/mod.rs`. The HKDF case uses RFC 5869 Test Case 1 to prove the wiring (IKM = 22×0x0b, salt = 0x000102…0c, info = `b"chisel-kek-v1"` — note: we pin our own info string, so the assertion is determinism + correct length + salt-sensitivity, plus a direct-against-`hkdf` cross-check using our exact info, which is the honest known-answer for our construction): +```rust + #[test] + fn derive_kek_hkdf_matches_reference_construction() { + // RFC 5869 Test Case 1 inputs (IKM/salt), our pinned info string. + let ikm = [0x0bu8; 22]; + let salt: [u8; SALT_LEN] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, + 0x0e, 0x0f, + ]; + let key = Key::Raw(zeroize::Zeroizing::new(ikm.to_vec())); + let kek = derive_kek(&key, KdfId::Hkdf, &salt, &Argon2Params::default()).unwrap(); + + // Independent reference: run hkdf directly with our exact salt+info. + use hkdf::Hkdf; + use sha2::Sha256; + let hk = Hkdf::::new(Some(&salt), &ikm); + let mut expect = [0u8; 32]; + hk.expand(b"chisel-kek-v1", &mut expect).unwrap(); + assert_eq!(kek.as_bytes(), &expect); + } + + #[test] + fn derive_kek_hkdf_is_deterministic_and_salt_sensitive() { + let key = Key::Raw(zeroize::Zeroizing::new(vec![7u8; 32])); + let salt_a = [1u8; SALT_LEN]; + let salt_b = [2u8; SALT_LEN]; + let p = Argon2Params::default(); + let k1 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap(); + let k2 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap(); + let k3 = derive_kek(&key, KdfId::Hkdf, &salt_b, &p).unwrap(); + assert_eq!(k1.as_bytes(), k2.as_bytes(), "same input must be deterministic"); + assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge"); + } + + #[test] + fn derive_kek_argon2_roundtrips_and_is_salt_sensitive() { + // Cheap params so the test is fast (real defaults are 19 MiB). + let fast = Argon2Params { m_cost: 256, t_cost: 1, p_cost: 1 }; + let key = Key::Passphrase(zeroize::Zeroizing::new("correct horse".to_string())); + let salt_a = [9u8; SALT_LEN]; + let salt_b = [8u8; SALT_LEN]; + let k1 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap(); + let k2 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap(); + let k3 = derive_kek(&key, KdfId::Argon2id, &salt_b, &fast).unwrap(); + assert_eq!(k1.as_bytes(), k2.as_bytes(), "Argon2id must be deterministic"); + assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge"); + assert_ne!(k1.as_bytes(), &[0u8; 32]); + } + + #[test] + fn derive_kek_argon2_rejects_zero_memory() { + let bad = Argon2Params { m_cost: 0, t_cost: 1, p_cost: 1 }; + let key = Key::Passphrase(zeroize::Zeroizing::new("x".to_string())); + let err = derive_kek(&key, KdfId::Argon2id, &[0u8; SALT_LEN], &bad).unwrap_err(); + assert_eq!(err, CryptoError::Kdf); + } +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test --package chisel crypto::tests::derive_kek` Expected: FAIL — `cannot find function derive_kek`. + +- [ ] **Step 3: Implement** +Add to `src/crypto/mod.rs` (above the `tests` module): +```rust +use argon2::{Algorithm, Argon2, Params, Version}; +use hkdf::Hkdf; +use sha2::Sha256; + +/// HKDF info string binding derived KEKs to this construction/version. Changing +/// it is a format break (existing slots would stop unwrapping); versioned so a +/// future KDF revision can coexist. +const KEK_INFO: &[u8] = b"chisel-kek-v1"; + +/// Derive a 256-bit KEK from the client key and a slot's salt/params. +/// +/// Dispatch is on `kdf`, NOT on the `Key` variant: the slot records which KDF +/// produced it, and that is the authority. A `Raw` key is the IKM for HKDF; a +/// `Passphrase` is the password for Argon2id. (A mismatched pairing — e.g. a +/// passphrase with KdfId::Hkdf — still derives a deterministic KEK; it simply +/// won't match the slot that was written with the other KDF, surfacing as an +/// unwrap Auth failure one layer up. The slot's kdf_id is the single source of +/// truth, so we never guess from the variant.) +pub fn derive_kek( + key: &Key, + kdf: KdfId, + salt: &[u8; SALT_LEN], + params: &Argon2Params, +) -> Result { + let ikm: &[u8] = match key { + Key::Raw(bytes) => bytes.as_slice(), + Key::Passphrase(s) => s.as_bytes(), + }; + let mut okm = [0u8; 32]; + match kdf { + KdfId::Hkdf => { + let hk = Hkdf::::new(Some(salt), ikm); + hk.expand(KEK_INFO, &mut okm).map_err(|_| CryptoError::Kdf)?; + } + KdfId::Argon2id => { + let p = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(32)) + .map_err(|_| CryptoError::Kdf)?; + let a2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, p); + a2.hash_password_into(ikm, salt, &mut okm) + .map_err(|_| CryptoError::Kdf)?; + } + } + Ok(Kek::from_bytes(okm)) +} +``` +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test --package chisel crypto::tests::derive_kek` Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(crypto): derive_kek dispatching HKDF-SHA256 and Argon2id" +``` + +--- + +### Task 1.4: DEK wrap / unwrap under a KEK + +**Files:** +- Modify: `src/crypto/mod.rs` (add an internal AEAD helper, `wrap_dek`, `unwrap_dek`; extend `tests`) +- Test: `#[cfg(test)] mod tests` in `src/crypto/mod.rs` + +**Interfaces:** +- Consumes: `Kek`, `Dek`, `CryptoError`, `NONCE_LEN`, `TAG_LEN`, `DEK_LEN` (Task 1.2) +- Produces: +```rust +pub fn wrap_dek(kek: &Kek, dek: &Dek, wrap_nonce: &[u8; NONCE_LEN], aad: &[u8]) -> ([u8; DEK_LEN], [u8; TAG_LEN]); +pub fn unwrap_dek(kek: &Kek, wrapped: &[u8; DEK_LEN], tag: &[u8; TAG_LEN], wrap_nonce: &[u8; NONCE_LEN], aad: &[u8]) -> Result; +``` +Also produces the crate-internal AEAD primitives that Task 1.5 reuses: +```rust +fn seal_detached(key: &[u8; 32], nonce: &[u8; NONCE_LEN], aad: &[u8], plaintext: &[u8]) -> (Vec, [u8; TAG_LEN]); +fn open_detached(key: &[u8; 32], nonce: &[u8; NONCE_LEN], aad: &[u8], ciphertext: &[u8], tag: &[u8; TAG_LEN]) -> Result, CryptoError>; +``` + +- [ ] **Step 1: Write the failing test** +Add to the `tests` module: +```rust + #[test] + fn wrap_unwrap_roundtrip() { + let kek = Kek::from_bytes([3u8; 32]); + let dek = Dek::from_bytes([42u8; DEK_LEN]); + let nonce = [5u8; NONCE_LEN]; + let aad = b"slot-meta"; + let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, aad); + assert_ne!(&wrapped, dek.as_bytes(), "wrapped DEK must not equal plaintext DEK"); + let out = unwrap_dek(&kek, &wrapped, &tag, &nonce, aad).unwrap(); + assert_eq!(out.as_bytes(), dek.as_bytes()); + } + + #[test] + fn unwrap_wrong_kek_is_auth() { + let dek = Dek::from_bytes([42u8; DEK_LEN]); + let nonce = [5u8; NONCE_LEN]; + let aad = b"slot-meta"; + let (wrapped, tag) = wrap_dek(&Kek::from_bytes([3u8; 32]), &dek, &nonce, aad); + let err = unwrap_dek(&Kek::from_bytes([4u8; 32]), &wrapped, &tag, &nonce, aad).unwrap_err(); + assert_eq!(err, CryptoError::Auth); + } + + #[test] + fn unwrap_tampered_tag_is_auth() { + let kek = Kek::from_bytes([3u8; 32]); + let dek = Dek::from_bytes([42u8; DEK_LEN]); + let nonce = [5u8; NONCE_LEN]; + let aad = b"slot-meta"; + let (wrapped, mut tag) = wrap_dek(&kek, &dek, &nonce, aad); + tag[0] ^= 0x01; + let err = unwrap_dek(&kek, &wrapped, &tag, &nonce, aad).unwrap_err(); + assert_eq!(err, CryptoError::Auth); + } + + #[test] + fn unwrap_wrong_aad_is_auth() { + let kek = Kek::from_bytes([3u8; 32]); + let dek = Dek::from_bytes([42u8; DEK_LEN]); + let nonce = [5u8; NONCE_LEN]; + let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, b"slot-meta-A"); + let err = unwrap_dek(&kek, &wrapped, &tag, &nonce, b"slot-meta-B").unwrap_err(); + assert_eq!(err, CryptoError::Auth); + } +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test --package chisel crypto::tests` Expected: FAIL — `cannot find function wrap_dek` / `unwrap_dek`. + +- [ ] **Step 3: Implement** +Add to `src/crypto/mod.rs` (above the `tests` module): +```rust +use chacha20poly1305::aead::AeadInPlace; +use chacha20poly1305::{Key as AeadKey, KeyInit, XChaCha20Poly1305, XNonce}; + +/// Detached AEAD seal: ciphertext is the same length as plaintext, the 16-byte +/// Poly1305 tag is returned separately. Detached suits our fixed page layout +/// (ciphertext occupies a known 8192-byte slot, tag a known 16-byte slot). +fn seal_detached( + key: &[u8; 32], + nonce: &[u8; NONCE_LEN], + aad: &[u8], + plaintext: &[u8], +) -> (Vec, [u8; TAG_LEN]) { + let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key)); + let mut buf = plaintext.to_vec(); + let tag = cipher + .encrypt_in_place_detached(XNonce::from_slice(nonce), aad, &mut buf) + .expect("XChaCha20-Poly1305 encrypt cannot fail for in-range lengths"); + let mut tag_arr = [0u8; TAG_LEN]; + tag_arr.copy_from_slice(&tag); + (buf, tag_arr) +} + +/// Detached AEAD open. Any tag mismatch (wrong key, tampered ct/tag, wrong AAD, +/// wrong nonce) maps to CryptoError::Auth. On failure the in-place buffer is left +/// scrubbed by the AEAD impl, so no partial plaintext escapes. +fn open_detached( + key: &[u8; 32], + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ciphertext: &[u8], + tag: &[u8; TAG_LEN], +) -> Result, CryptoError> { + let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key)); + let mut buf = ciphertext.to_vec(); + cipher + .decrypt_in_place_detached( + XNonce::from_slice(nonce), + aad, + &mut buf, + tag.as_slice().into(), + ) + .map_err(|_| CryptoError::Auth)?; + Ok(buf) +} + +/// Wrap (encrypt) the DEK under a KEK. `aad` binds the slot's metadata +/// (kdf_id, salt, Argon2 params) so an attacker cannot tamper a slot's +/// parameters to force a mis-derivation. Returns (wrapped_dek, wrap_tag). +pub fn wrap_dek( + kek: &Kek, + dek: &Dek, + wrap_nonce: &[u8; NONCE_LEN], + aad: &[u8], +) -> ([u8; DEK_LEN], [u8; TAG_LEN]) { + let (ct, tag) = seal_detached(kek.as_bytes(), wrap_nonce, aad, dek.as_bytes()); + let mut wrapped = [0u8; DEK_LEN]; + wrapped.copy_from_slice(&ct); + (wrapped, tag) +} + +/// Unwrap (decrypt + authenticate) the DEK. A successful unwrap IS the proof +/// that the client key (hence KEK) is correct — there is no separate verifier. +/// Failure → CryptoError::Auth (wrong key/passphrase, or tampered slot). +pub fn unwrap_dek( + kek: &Kek, + wrapped: &[u8; DEK_LEN], + tag: &[u8; TAG_LEN], + wrap_nonce: &[u8; NONCE_LEN], + aad: &[u8], +) -> Result { + let pt = open_detached(kek.as_bytes(), wrap_nonce, aad, wrapped, tag)?; + let mut dek = [0u8; DEK_LEN]; + dek.copy_from_slice(&pt); + Ok(Dek::from_bytes(dek)) +} +``` +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test --package chisel crypto::tests` Expected: PASS (wrap/unwrap tests green alongside earlier ones). + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(crypto): DEK wrap/unwrap under KEK with detached XChaCha20-Poly1305" +``` + +--- + +### Task 1.5: PageCipher — whole-page and variable-length body seal/open + +**Files:** +- Modify: `src/crypto/mod.rs` (add `PageCipher`; extend `tests`) +- Test: `#[cfg(test)] mod tests` in `src/crypto/mod.rs` + +**Interfaces:** +- Consumes: `Dek`, `CryptoError`, `ENC_PAGE_SIZE`, `NONCE_LEN`, `TAG_LEN`, `random_array`, `seal_detached`/`open_detached` (Tasks 1.2/1.4) +- Produces: +```rust +pub struct PageCipher { /* dek + constructed XChaCha20Poly1305 */ } +impl PageCipher { + pub fn new(dek: Dek) -> Self; + pub fn seal(&self, page_id: u64, plaintext: &[u8; 8192]) -> [u8; ENC_PAGE_SIZE]; + pub fn open(&self, page_id: u64, ondisk: &[u8; ENC_PAGE_SIZE]) -> Result<[u8; 8192], CryptoError>; + pub fn seal_body(&self, aad: &[u8], plaintext: &[u8]) -> ([u8; NONCE_LEN], [u8; TAG_LEN], Vec); + pub fn open_body(&self, aad: &[u8], nonce: &[u8; NONCE_LEN], tag: &[u8; TAG_LEN], ct: &[u8]) -> Result, CryptoError>; +} +``` + +- [ ] **Step 1: Write the failing test** +Add to the `tests` module: +```rust + #[test] + fn page_seal_open_roundtrip() { + let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN])); + let mut page = [0u8; 8192]; + for (i, b) in page.iter_mut().enumerate() { + *b = (i % 251) as u8; + } + let blob = pc.seal(7, &page); + assert_eq!(blob.len(), ENC_PAGE_SIZE); + let out = pc.open(7, &blob).unwrap(); + assert_eq!(out, page); + } + + #[test] + fn page_seal_layout_is_ct_tag_nonce() { + let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN])); + let page = [0xABu8; 8192]; + let blob = pc.seal(0, &page); + // ciphertext occupies 0..8192, tag 8192..8208, nonce 8208..8232. + assert_ne!(&blob[0..8192], &page[..], "ciphertext must differ from plaintext"); + } + + #[test] + fn page_open_wrong_page_id_is_auth() { + // AAD = page_id gives anti-relocation: a page sealed at id 7 must not + // authenticate at id 8. + let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN])); + let page = [9u8; 8192]; + let blob = pc.seal(7, &page); + assert_eq!(pc.open(8, &blob).unwrap_err(), CryptoError::Auth); + } + + #[test] + fn page_open_byte_flip_is_auth() { + let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN])); + let page = [9u8; 8192]; + let mut blob = pc.seal(7, &page); + blob[100] ^= 0x01; // flip a ciphertext byte + assert_eq!(pc.open(7, &blob).unwrap_err(), CryptoError::Auth); + } + + #[test] + fn page_two_seals_use_different_nonces() { + // Random per-write nonce (spec §2.1): two seals of the same page must + // produce different on-disk blobs (different nonce ⇒ different ct+tag). + let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN])); + let page = [9u8; 8192]; + let a = pc.seal(7, &page); + let b = pc.seal(7, &page); + assert_ne!(&a[..], &b[..], "nonce reuse: identical blobs for same page"); + // Both still open correctly. + assert_eq!(pc.open(7, &a).unwrap(), page); + assert_eq!(pc.open(7, &b).unwrap(), page); + } + + #[test] + fn body_seal_open_roundtrip() { + let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN])); + let body = b"root pointers + named_roots".to_vec(); + let aad = b"sb-identity"; + let (nonce, tag, ct) = pc.seal_body(aad, &body); + assert_eq!(ct.len(), body.len(), "body cipher is length-preserving"); + let out = pc.open_body(aad, &nonce, &tag, &ct).unwrap(); + assert_eq!(out, body); + } + + #[test] + fn body_open_wrong_aad_is_auth() { + let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN])); + let body = b"secret".to_vec(); + let (nonce, tag, ct) = pc.seal_body(b"sb-A", &body); + assert_eq!(pc.open_body(b"sb-B", &nonce, &tag, &ct).unwrap_err(), CryptoError::Auth); + } +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test --package chisel crypto::tests` Expected: FAIL — `cannot find type PageCipher`. + +- [ ] **Step 3: Implement** +Add to `src/crypto/mod.rs` (above the `tests` module). Reuses `seal_detached`/`open_detached` from Task 1.4 and `random_array` from Task 1.2: +```rust +/// Holds the DEK and performs the two seal/open transforms the engine needs: +/// whole-page (fixed 8192→8232) and variable-length body (superblock sub-blob). +/// Lives in the page-cache layer in later phases; here it is fully standalone. +/// Constructs the AEAD cipher once and reuses it across calls. +pub struct PageCipher { + dek: Dek, +} + +impl PageCipher { + pub fn new(dek: Dek) -> Self { + PageCipher { dek } + } + + /// Seal a full 8192-byte plaintext page image into the 8232-byte on-disk + /// blob: `ciphertext(8192) ‖ tag(16) ‖ nonce(24)`. AAD = page_id LE bytes + /// (anti-relocation). A fresh random 192-bit nonce per call (spec §2.1) — + /// safe under shadow-paging page reuse, and stored in the clear. + pub fn seal(&self, page_id: u64, plaintext: &[u8; 8192]) -> [u8; ENC_PAGE_SIZE] { + let nonce = random_array::(); + let aad = page_id.to_le_bytes(); + let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, &aad, plaintext); + let mut out = [0u8; ENC_PAGE_SIZE]; + out[0..8192].copy_from_slice(&ct); + out[8192..8208].copy_from_slice(&tag); + out[8208..8232].copy_from_slice(&nonce); + out + } + + /// Open an 8232-byte on-disk blob back to the 8192-byte plaintext page. + /// AAD = page_id LE. Any authentication failure → CryptoError::Auth (the + /// engine maps this to DecryptionFailed at the page-read site). + pub fn open(&self, page_id: u64, ondisk: &[u8; ENC_PAGE_SIZE]) -> Result<[u8; 8192], CryptoError> { + let ct = &ondisk[0..8192]; + let mut tag = [0u8; TAG_LEN]; + tag.copy_from_slice(&ondisk[8192..8208]); + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(&ondisk[8208..8232]); + let aad = page_id.to_le_bytes(); + let pt = open_detached(self.dek.as_bytes(), &nonce, &aad, ct, &tag)?; + let mut page = [0u8; 8192]; + page.copy_from_slice(&pt); + Ok(page) + } + + /// Seal a variable-length body (the superblock sensitive sub-blob). Returns + /// (nonce, tag, ciphertext); the caller lays these out in the reserved + /// region. AAD binds the body to the superblock's identity (anti-splicing). + pub fn seal_body(&self, aad: &[u8], plaintext: &[u8]) -> ([u8; NONCE_LEN], [u8; TAG_LEN], Vec) { + let nonce = random_array::(); + let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, aad, plaintext); + (nonce, tag, ct) + } + + /// Open a variable-length body sealed by `seal_body`. AAD must match the + /// superblock identity used at seal time, else CryptoError::Auth. + pub fn open_body( + &self, + aad: &[u8], + nonce: &[u8; NONCE_LEN], + tag: &[u8; TAG_LEN], + ct: &[u8], + ) -> Result, CryptoError> { + open_detached(self.dek.as_bytes(), nonce, aad, ct, tag) + } +} +``` +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test --package chisel crypto` Expected: PASS (all PageCipher tests plus every prior crypto test). + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(crypto): PageCipher whole-page and body seal/open" +``` + +--- + +### Task 1.6: Zeroization guard + full-suite green + clippy + +**Files:** +- Modify: `src/crypto/mod.rs` (one zeroization test; no new production code unless clippy flags something) +- Test: `#[cfg(test)] mod tests` in `src/crypto/mod.rs` + +**Interfaces:** +- Consumes: all Phase-1 types +- Produces: nothing new — this task certifies the module against the full suite and lint gate, matching the project's "lint + full `cargo test` before push" rule. + +- [ ] **Step 1: Write the failing test** +Add a test asserting the zeroizing wrappers are wired (compile-level proof that `Dek`/`Kek` hold `Zeroizing`, and a behavioral proof that `Key` drops without leaking via a public accessor). `Zeroizing` zeroes on drop; we cannot observe freed memory safely, so the honest, runnable check is that the types expose no owned-bytes copy that escapes and that a `Zeroizing`-backed clone is independent: +```rust + #[test] + fn dek_clone_is_independent_zeroizing_copy() { + let d = Dek::from_bytes([7u8; DEK_LEN]); + let c = d.clone(); + assert_eq!(d.as_bytes(), c.as_bytes()); + // Dropping the clone must not affect the original (independent buffers). + drop(c); + assert_eq!(d.as_bytes(), &[7u8; DEK_LEN]); + } + + #[test] + fn key_variants_construct_from_zeroizing() { + // Compile + construct proof that Key wraps Zeroizing for both variants. + let _raw = Key::Raw(zeroize::Zeroizing::new(vec![1u8, 2, 3])); + let _pass = Key::Passphrase(zeroize::Zeroizing::new("pw".to_string())); + // Clone works (needed by Options/rotation). + let _r2 = _raw.clone(); + } +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test --package chisel crypto::tests::dek_clone_is_independent_zeroizing_copy` Expected: FAIL only if Step-1 names don't yet exist — if `Dek::clone` and `Key::clone` from Tasks 1.2 are already present, this compiles; in that case the gating signal for this task is the lint/full-suite step below. (Write the test first regardless; it is the deliverable's assertion.) + +- [ ] **Step 3: Implement** +No new production code is expected — the zeroizing types were defined in Task 1.2. If `cargo clippy` flags anything in `src/crypto/mod.rs` (e.g. a needless `to_vec`, a doc-list indent), fix it minimally here. Example fix shape if clippy wants `Default` derived or a lint silenced: +```rust +// (apply only the specific clippy fix reported; no speculative changes) +``` +- [ ] **Step 4: Run test, verify it passes** +Run the full gate the project requires before any push (plain `cargo test`, not `--lib`, plus clippy with warnings-as-errors): +```bash +cargo test && cargo clippy --workspace --all-targets -- -D warnings && cargo fmt --check +``` +Expected: PASS — every crypto test green, no clippy warnings, formatted. + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "test(crypto): zeroization guards; clippy/fmt clean for crypto core" +``` + +--- + +Phase 1 deliverable: `src/crypto/mod.rs` exporting `ENC_PAGE_SIZE`, `NONCE_LEN`, `TAG_LEN`, `DEK_LEN`, `SALT_LEN`, `Key`, `Dek`, `Kek`, `KdfId`, `Argon2Params`, `CryptoError`, `random_dek`, `random_array`, `derive_kek`, `wrap_dek`, `unwrap_dek`, and `PageCipher` (with `new`/`seal`/`open`/`seal_body`/`open_body`) — the complete contract Phases 2–5 consume. + +→ skipped: splitting crypto into per-concern files (one consumer, ~400 lines); a `CryptoError::Display`/`From` impl (the engine maps variants to `ChiselError` in Phase 2, where the mapping context lives). Add the split when the file crosses 2000 lines; add the `From` impl in Phase 2 where `ChiselError` is in scope. + +--- + +## Phase 2: Superblock crypto-header, encrypted body, and open/create key flow + +This phase adds the on-disk crypto-header (key-slot table) and DEK-sealed body to the superblock, and wires the create/open key flow so an unwrapped `Dek` (held zeroizing for the session) reaches `TransactionManager`. It consumes Phase-1 `PageCipher`, `derive_kek`, `wrap_dek`/`unwrap_dek`, `random_dek`, `random_array`. + +Verified offsets used below (read from `src/superblock.rs`): reserved region starts at `FREEMAP_DEPTH_OFFSET + 4 = 324` and runs to `CHECKSUM_OFFSET = 8184`. The crypto-header is placed at byte 324; the sealed body follows the key-slot table. The error variants `NoEncryptionKey`/`InvalidEncryptionKey`/`EncryptionNotSupported`/`DecryptionFailed` are defined once in **Task 4.1, implemented first** (see the plan header's execution-order exception), so the tasks below use them directly — no placeholder variants are introduced. + +--- + +### Task 2.1: KeySlot + CryptoHeader serialize/deserialize into the reserved region + +**Files:** +- Create: `src/superblock/crypto_header.rs` +- Modify: `src/superblock.rs:34` (add `pub mod` / `use` for the new submodule — currently `superblock.rs` is a flat file; convert the file's top to declare `mod crypto_header;` and re-export. The struct/consts live in the new file, leaving `superblock.rs`'s body untouched otherwise.) +- Test: `#[cfg(test)] mod tests` in `src/superblock/crypto_header.rs` + +**Interfaces:** +- Consumes (Phase 1): `crypto::{NONCE_LEN, TAG_LEN, DEK_LEN, SALT_LEN, Argon2Params}`. +- Produces: + ```rust + pub const KEY_SLOT_COUNT: usize = 8; + pub const KEY_SLOT_SIZE: usize = 128; + pub const CRYPTO_HEADER_OFFSET: usize = 324; // == FREEMAP_DEPTH_OFFSET + 4 + pub const CRYPTO_HEADER_SIZE: usize = 8 + KEY_SLOT_COUNT * KEY_SLOT_SIZE; // 1032 + pub struct KeySlot { pub state: u8, pub kdf_id: u8, pub argon2: crypto::Argon2Params, + pub salt: [u8; SALT_LEN], pub wrap_nonce: [u8; NONCE_LEN], + pub wrapped_dek: [u8; DEK_LEN], pub wrap_tag: [u8; TAG_LEN] } + pub struct CryptoHeader { pub algorithm: u8, pub stride: u32, pub slots: [KeySlot; KEY_SLOT_COUNT] } + impl CryptoHeader { pub fn serialize_into(&self, buf: &mut [u8; PAGE_SIZE]); pub fn deserialize(buf: &[u8; PAGE_SIZE]) -> Option; } + impl KeySlot { pub const EMPTY: KeySlot; pub fn is_active(&self) -> bool; } + ``` + +- [ ] **Step 1: Write the failing test** +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::Argon2Params; + use crate::page::{self, PAGE_SIZE}; + + fn sample_slot(state: u8) -> KeySlot { + KeySlot { + state, + kdf_id: 1, + argon2: Argon2Params { m_cost: 19456, t_cost: 2, p_cost: 1 }, + salt: [7u8; 16], + wrap_nonce: [9u8; 24], + wrapped_dek: [3u8; 32], + wrap_tag: [5u8; 16], + } + } + + #[test] + fn crypto_header_round_trips_through_reserved_region() { + let mut slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + slots[0] = sample_slot(1); // active + slots[3] = sample_slot(1); // active + let header = CryptoHeader { algorithm: 1, stride: 8232, slots }; + + let mut buf = [0u8; PAGE_SIZE]; + header.serialize_into(&mut buf); + + // Crypto-header must live entirely inside the reserved region [324, 8184). + assert!(CRYPTO_HEADER_OFFSET + CRYPTO_HEADER_SIZE <= page::CHECKSUM_OFFSET); + // Bytes before the header (the existing fields + reserved gap up to 324) + // are NOT touched by serialize_into. + assert_eq!(buf[..CRYPTO_HEADER_OFFSET], [0u8; CRYPTO_HEADER_OFFSET][..]); + + let back = CryptoHeader::deserialize(&buf).expect("active header must deserialize"); + assert_eq!(back.algorithm, 1); + assert_eq!(back.stride, 8232); + assert!(back.slots[0].is_active()); + assert!(!back.slots[1].is_active()); + assert!(back.slots[3].is_active()); + assert_eq!(back.slots[0].salt, [7u8; 16]); + assert_eq!(back.slots[0].wrap_nonce, [9u8; 24]); + assert_eq!(back.slots[0].wrapped_dek, [3u8; 32]); + assert_eq!(back.slots[0].wrap_tag, [5u8; 16]); + assert_eq!(back.slots[0].argon2.m_cost, 19456); + } + + #[test] + fn deserialize_returns_none_for_plaintext_db() { + // A zeroed reserved region (plaintext DB) has algorithm == 0 -> None. + let buf = [0u8; PAGE_SIZE]; + assert!(CryptoHeader::deserialize(&buf).is_none()); + } +} +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test crypto_header_round_trips_through_reserved_region` Expected: FAIL (module does not exist yet) +- [ ] **Step 3: Implement** + +Create `src/superblock/crypto_header.rs`: +```rust +// superblock/crypto_header.rs — the plaintext crypto-header that lives in the +// superblock's reserved region for encrypted databases. Holds the algorithm id, +// the on-disk page stride, and the 8-slot key-slot table (each slot wraps the +// per-DB DEK under a KEK derived from one client key). For PLAINTEXT databases +// the reserved region stays zeroed and `deserialize` returns None (algorithm 0). +// +// On-disk layout (all inside the superblock's reserved region, after freemap_depth): +// 324..325 algorithm (u8; 1 = XChaCha20-Poly1305, 0 = none/plaintext) +// 325..329 stride (u32 LE; 8232 for encrypted, validated by the engine) +// 329..332 reserved (zero) +// 332..332+8*128 the 8 key-slot records, 128 bytes each +// Total = 8 + 8*128 = 1032 bytes, ending at 1356 — well inside CHECKSUM_OFFSET (8184). +// +// Key-slot record (128 bytes; trailing bytes reserved/zero): +// 0 state (u8; 1 = active, 0 = empty) +// 1 kdf_id (u8; 1 = HKDF, 2 = Argon2id) +// 2..14 argon2 params: m_cost(u32) | t_cost(u32) | p_cost(u32) (zero for HKDF) +// 14..30 salt (16) +// 30..54 wrap_nonce (24) +// 54..86 wrapped_dek (32) +// 86..102 wrap_tag (16) +// 102..128 reserved + +use crate::crypto::{Argon2Params, DEK_LEN, NONCE_LEN, SALT_LEN, TAG_LEN}; +use crate::page::{self, PAGE_SIZE}; + +pub const KEY_SLOT_COUNT: usize = 8; +pub const KEY_SLOT_SIZE: usize = 128; +// Immediately after freemap_depth (bytes 320..324). Keep in lockstep with +// superblock.rs's FREEMAP_DEPTH_OFFSET (320) + 4. +pub const CRYPTO_HEADER_OFFSET: usize = 324; +pub const CRYPTO_HEADER_SIZE: usize = 8 + KEY_SLOT_COUNT * KEY_SLOT_SIZE; + +const SLOT_TABLE_OFFSET: usize = CRYPTO_HEADER_OFFSET + 8; + +/// Algorithm id stored in the header. 0 means "no encryption" (plaintext DB); +/// the only supported nonzero value today is 1 = XChaCha20-Poly1305. +pub const ALGO_XCHACHA20POLY1305: u8 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeySlot { + pub state: u8, + pub kdf_id: u8, + pub argon2: Argon2Params, + pub salt: [u8; SALT_LEN], + pub wrap_nonce: [u8; NONCE_LEN], + pub wrapped_dek: [u8; DEK_LEN], + pub wrap_tag: [u8; TAG_LEN], +} + +impl KeySlot { + pub const EMPTY: KeySlot = KeySlot { + state: 0, + kdf_id: 0, + argon2: Argon2Params { m_cost: 0, t_cost: 0, p_cost: 0 }, + salt: [0u8; SALT_LEN], + wrap_nonce: [0u8; NONCE_LEN], + wrapped_dek: [0u8; DEK_LEN], + wrap_tag: [0u8; TAG_LEN], + }; + + /// True if this slot holds a usable wrapped DEK (state byte == 1). + pub fn is_active(&self) -> bool { + self.state == 1 + } + + /// The bytes an unwrap operation must authenticate as AAD: the slot's own + /// metadata up to but excluding the wrapped_dek/tag. Binds the wrap to its + /// salt/params/nonce so a slot can't be transplanted between DBs. + pub fn aad(&self) -> [u8; 1 + 1 + 12 + SALT_LEN + NONCE_LEN] { + let mut a = [0u8; 1 + 1 + 12 + SALT_LEN + NONCE_LEN]; + a[0] = self.state; + a[1] = self.kdf_id; + a[2..6].copy_from_slice(&self.argon2.m_cost.to_le_bytes()); + a[6..10].copy_from_slice(&self.argon2.t_cost.to_le_bytes()); + a[10..14].copy_from_slice(&self.argon2.p_cost.to_le_bytes()); + a[14..14 + SALT_LEN].copy_from_slice(&self.salt); + a[14 + SALT_LEN..14 + SALT_LEN + NONCE_LEN].copy_from_slice(&self.wrap_nonce); + a + } + + fn write_into(&self, slot: &mut [u8]) { + slot[0] = self.state; + slot[1] = self.kdf_id; + slot[2..6].copy_from_slice(&self.argon2.m_cost.to_le_bytes()); + slot[6..10].copy_from_slice(&self.argon2.t_cost.to_le_bytes()); + slot[10..14].copy_from_slice(&self.argon2.p_cost.to_le_bytes()); + slot[14..14 + SALT_LEN].copy_from_slice(&self.salt); + slot[30..30 + NONCE_LEN].copy_from_slice(&self.wrap_nonce); + slot[54..54 + DEK_LEN].copy_from_slice(&self.wrapped_dek); + slot[86..86 + TAG_LEN].copy_from_slice(&self.wrap_tag); + } + + fn read_from(slot: &[u8]) -> KeySlot { + let mut k = KeySlot::EMPTY; + k.state = slot[0]; + k.kdf_id = slot[1]; + k.argon2 = Argon2Params { + m_cost: u32::from_le_bytes(slot[2..6].try_into().unwrap()), + t_cost: u32::from_le_bytes(slot[6..10].try_into().unwrap()), + p_cost: u32::from_le_bytes(slot[10..14].try_into().unwrap()), + }; + k.salt.copy_from_slice(&slot[14..14 + SALT_LEN]); + k.wrap_nonce.copy_from_slice(&slot[30..30 + NONCE_LEN]); + k.wrapped_dek.copy_from_slice(&slot[54..54 + DEK_LEN]); + k.wrap_tag.copy_from_slice(&slot[86..86 + TAG_LEN]); + k + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CryptoHeader { + pub algorithm: u8, + pub stride: u32, + pub slots: [KeySlot; KEY_SLOT_COUNT], +} + +impl CryptoHeader { + /// Write the crypto-header into the superblock's reserved region. Touches + /// only [CRYPTO_HEADER_OFFSET, CRYPTO_HEADER_OFFSET+CRYPTO_HEADER_SIZE); + /// the caller stamps the page checksum afterward. + pub fn serialize_into(&self, buf: &mut [u8; PAGE_SIZE]) { + debug_assert!(CRYPTO_HEADER_OFFSET + CRYPTO_HEADER_SIZE <= page::CHECKSUM_OFFSET); + buf[CRYPTO_HEADER_OFFSET] = self.algorithm; + buf[CRYPTO_HEADER_OFFSET + 1..CRYPTO_HEADER_OFFSET + 5] + .copy_from_slice(&self.stride.to_le_bytes()); + for (i, slot) in self.slots.iter().enumerate() { + let base = SLOT_TABLE_OFFSET + i * KEY_SLOT_SIZE; + slot.write_into(&mut buf[base..base + KEY_SLOT_SIZE]); + } + } + + /// Read the crypto-header. Returns None for a plaintext DB (algorithm byte + /// 0), which is how callers distinguish "encrypted" from "plaintext". + pub fn deserialize(buf: &[u8; PAGE_SIZE]) -> Option { + let algorithm = buf[CRYPTO_HEADER_OFFSET]; + if algorithm == 0 { + return None; + } + let stride = u32::from_le_bytes( + buf[CRYPTO_HEADER_OFFSET + 1..CRYPTO_HEADER_OFFSET + 5] + .try_into() + .unwrap(), + ); + let mut slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + for (i, slot) in slots.iter_mut().enumerate() { + let base = SLOT_TABLE_OFFSET + i * KEY_SLOT_SIZE; + *slot = KeySlot::read_from(&buf[base..base + KEY_SLOT_SIZE]); + } + Some(CryptoHeader { algorithm, stride, slots }) + } +} +``` + +Convert `src/superblock.rs` to a module directory by adding, near the top (after the existing `use` block at line 34–35): +```rust +mod crypto_header; +pub use crypto_header::{ + CryptoHeader, KeySlot, ALGO_XCHACHA20POLY1305, CRYPTO_HEADER_OFFSET, CRYPTO_HEADER_SIZE, + KEY_SLOT_COUNT, KEY_SLOT_SIZE, +}; +``` +(Move `src/superblock.rs` to `src/superblock/mod.rs` so the submodule resolves; no other edits to that file's body.) + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test crypto_header` Expected: PASS +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(superblock): add crypto-header key-slot table in the reserved region" +``` + +--- + +### Task 2.2: DEK-sealed superblock body + `Superblock.encryption` field + +**Files:** +- Modify: `src/superblock/mod.rs:165` (add `pub encryption: Option` to the `Superblock` struct, after `freemap_depth`) +- Modify: `src/superblock/mod.rs:245` (`serialize` — gains an encrypted path that seals the sensitive fields into a body sub-blob) +- Modify: `src/superblock/mod.rs:287` (`deserialize` — reads the crypto-header; for encrypted DBs the sensitive fields can only be filled after the DEK is known, so `deserialize` returns the struct with placeholder sensitive fields plus the raw sealed body for a later `decrypt_body` call) +- Modify: `src/superblock/mod.rs:433` (`new_empty` — set `encryption: None`) +- Modify every test/struct-literal `Superblock { ... }` in `mod.rs` to add `encryption: None`, and the same literal in `src/transaction/commit.rs:116` +- Test: `#[cfg(test)] mod tests` in `src/superblock/mod.rs` + +**Interfaces:** +- Consumes (Phase 1): `crypto::PageCipher::{seal_body, open_body}`, `crypto::CryptoError`. +- Produces: + ```rust + pub struct Superblock { /* existing fields */, pub encryption: Option } + pub const SEALED_BODY_OFFSET: usize = CRYPTO_HEADER_OFFSET + CRYPTO_HEADER_SIZE; // 1356 + impl Superblock { + pub fn sb_identity_aad(&self) -> [u8; 24]; // magic|format_version|txn_counter|superblock_count + pub fn serialize_encrypted(&self, cipher: &PageCipher) -> [u8; PAGE_SIZE]; + pub fn decrypt_body(&mut self, cipher: &PageCipher, raw: &[u8; PAGE_SIZE]) -> Result<(), CryptoError>; + } + ``` + +The sealed body holds the sensitive fields (`root_handle_table_page`, `root_freemap_page`, `root_membership_index_page`, `total_pages`, `next_handle`, `freemap_depth`, `named_roots[8x32]`); in an encrypted serialization those byte ranges in the plaintext page are left zero, so `named_roots` are not visible in cleartext. + +- [ ] **Step 1: Write the failing test** +```rust +// inside src/superblock/mod.rs tests module +#[test] +fn encrypted_superblock_hides_sensitive_fields_and_round_trips() { + use crate::crypto::{random_dek, PageCipher}; + + let cipher = PageCipher::new(random_dek()); + let mut header_slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + header_slots[0].state = 1; + let header = CryptoHeader { algorithm: ALGO_XCHACHA20POLY1305, stride: 8232, slots: header_slots }; + + let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT); + sb.root_handle_table_page = 7; + sb.next_handle = 99; + sb.total_pages = 41; + sb.named_roots[0].name[..5].copy_from_slice(b"users"); + sb.named_roots[0].handle = 12345; + sb.encryption = Some(header); + + let buf = sb.serialize_encrypted(&cipher); + + // The named-root bytes (offset 52..308) must NOT be visible in cleartext. + assert_eq!(&buf[52..308], &[0u8; 256][..], "named_roots leaked in cleartext"); + // Bootstrap fields stay plaintext. + assert_eq!(u32::from_le_bytes(buf[0..4].try_into().unwrap()), MAGIC); + assert_eq!(u64::from_le_bytes(buf[8..16].try_into().unwrap()), sb.txn_counter); + // Crypto-header is plaintext. + let hdr = CryptoHeader::deserialize(&buf).expect("header present"); + assert_eq!(hdr.algorithm, ALGO_XCHACHA20POLY1305); + + // Deserialize gives a struct with the header but zeroed sensitive fields; + // decrypt_body fills them from the sealed sub-blob. + let mut back = Superblock::deserialize(&buf).expect("encrypted sb deserializes"); + assert!(back.encryption.is_some()); + assert_eq!(back.root_handle_table_page, 0); // not yet decrypted + back.decrypt_body(&cipher, &buf).expect("DEK opens body"); + assert_eq!(back.root_handle_table_page, 7); + assert_eq!(back.next_handle, 99); + assert_eq!(back.total_pages, 41); + assert_eq!(&back.named_roots[0].name[..5], b"users"); + assert_eq!(back.named_roots[0].handle, 12345); +} + +#[test] +fn wrong_dek_fails_body_authentication() { + use crate::crypto::{random_dek, PageCipher}; + let cipher = PageCipher::new(random_dek()); + let mut header_slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + header_slots[0].state = 1; + let header = CryptoHeader { algorithm: ALGO_XCHACHA20POLY1305, stride: 8232, slots: header_slots }; + let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT); + sb.encryption = Some(header); + let buf = sb.serialize_encrypted(&cipher); + + let wrong = PageCipher::new(random_dek()); + let mut back = Superblock::deserialize(&buf).unwrap(); + assert!(back.decrypt_body(&wrong, &buf).is_err()); +} +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test encrypted_superblock_hides_sensitive_fields_and_round_trips` Expected: FAIL (`encryption` field / methods do not exist) +- [ ] **Step 3: Implement** + +Add the field to the struct (after `freemap_depth` at line ~214): +```rust + pub freemap_depth: u32, + /// Crypto-header for an encrypted database. `None` for plaintext DBs, in + /// which case serialize/deserialize use the existing all-plaintext layout. + /// `Some` means the sensitive fields are sealed in a DEK-encrypted body + /// sub-blob and the in-memory copy is only valid after `decrypt_body`. + pub encryption: Option, +} +``` + +Add the byte-range constants and methods in `impl Superblock`: +```rust +// Offset where the DEK-sealed body sub-blob starts, immediately after the +// key-slot table. Layout of the sealed region: +// SEALED_BODY_OFFSET .. +24 nonce +// +24 .. +40 tag +// +40 .. +42 ciphertext length (u16 LE) +// +42 .. +42+ct_len ciphertext +pub const SEALED_BODY_OFFSET: usize = + crypto_header::CRYPTO_HEADER_OFFSET + crypto_header::CRYPTO_HEADER_SIZE; + +// Plaintext body layout (the bytes fed to seal_body): the sensitive fields in +// a fixed order. 6 u64 + freemap_depth(u32) + named_roots(8*32). +const BODY_LEN: usize = 8 * 5 + 4 + (NAMED_ROOT_COUNT * NAMED_ROOT_ENTRY_SIZE); + +impl Superblock { + /// AAD binding the sealed body and each key-slot's DEK wrap to this + /// superblock's plaintext identity, so a slot/body cannot be transplanted + /// to a different DB or replayed at a different txn_counter. + pub fn sb_identity_aad(&self) -> [u8; 24] { + let mut a = [0u8; 24]; + a[0..4].copy_from_slice(&self.magic.to_le_bytes()); + a[4..8].copy_from_slice(&self.format_version.to_le_bytes()); + a[8..16].copy_from_slice(&self.txn_counter.to_le_bytes()); + a[16..20].copy_from_slice(&self.superblock_count.to_le_bytes()); + // bytes 20..24 reserved/zero + a + } + + fn body_plaintext(&self) -> Vec { + let mut b = Vec::with_capacity(BODY_LEN); + b.extend_from_slice(&self.root_handle_table_page.to_le_bytes()); + b.extend_from_slice(&self.root_freemap_page.to_le_bytes()); + b.extend_from_slice(&self.root_membership_index_page.to_le_bytes()); + b.extend_from_slice(&self.total_pages.to_le_bytes()); + b.extend_from_slice(&self.next_handle.to_le_bytes()); + b.extend_from_slice(&self.freemap_depth.to_le_bytes()); + for entry in self.named_roots.iter() { + b.extend_from_slice(&entry.name); + b.extend_from_slice(&entry.handle.to_le_bytes()); + } + debug_assert_eq!(b.len(), BODY_LEN); + b + } + + fn load_body(&mut self, body: &[u8]) { + self.root_handle_table_page = u64::from_le_bytes(body[0..8].try_into().unwrap()); + self.root_freemap_page = u64::from_le_bytes(body[8..16].try_into().unwrap()); + self.root_membership_index_page = u64::from_le_bytes(body[16..24].try_into().unwrap()); + self.total_pages = u64::from_le_bytes(body[24..32].try_into().unwrap()); + self.next_handle = u64::from_le_bytes(body[32..40].try_into().unwrap()); + self.freemap_depth = u32::from_le_bytes(body[40..44].try_into().unwrap()); + let mut off = 44; + for entry in self.named_roots.iter_mut() { + entry.name.copy_from_slice(&body[off..off + NAMED_ROOT_NAME_LEN]); + entry.handle = + u64::from_le_bytes(body[off + NAMED_ROOT_NAME_LEN..off + NAMED_ROOT_NAME_LEN + 8].try_into().unwrap()); + off += NAMED_ROOT_ENTRY_SIZE; + } + } + + /// Serialize an encrypted superblock: plaintext bootstrap fields + crypto- + /// header in cleartext, sensitive fields sealed under the DEK. The sensitive + /// byte ranges of the plaintext page (named_roots @52..308, the root/page-id + /// scalars) are left ZERO so nothing sensitive is visible in cleartext. + pub fn serialize_encrypted(&self, cipher: &crate::crypto::PageCipher) -> [u8; PAGE_SIZE] { + let header = self + .encryption + .as_ref() + .expect("serialize_encrypted requires Superblock.encryption = Some"); + let mut buf = [0u8; PAGE_SIZE]; + // Plaintext bootstrap fields only. + buf[0..4].copy_from_slice(&self.magic.to_le_bytes()); + buf[4..8].copy_from_slice(&self.format_version.to_le_bytes()); + buf[8..16].copy_from_slice(&self.txn_counter.to_le_bytes()); + buf[48..52].copy_from_slice(&self.page_size.to_le_bytes()); + buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4] + .copy_from_slice(&self.superblock_count.to_le_bytes()); + // Crypto-header (plaintext). + header.serialize_into(&mut buf); + // Sealed body. + let aad = self.sb_identity_aad(); + let (nonce, tag, ct) = cipher.seal_body(&aad, &self.body_plaintext()); + let base = Self::SEALED_BODY_OFFSET; + buf[base..base + NONCE_LEN].copy_from_slice(&nonce); + buf[base + NONCE_LEN..base + NONCE_LEN + TAG_LEN].copy_from_slice(&tag); + buf[base + NONCE_LEN + TAG_LEN..base + NONCE_LEN + TAG_LEN + 2] + .copy_from_slice(&(ct.len() as u16).to_le_bytes()); + let coff = base + NONCE_LEN + TAG_LEN + 2; + buf[coff..cof_end(coff, ct.len())].copy_from_slice(&ct); + page::stamp_checksum(&mut buf); + buf + } + + /// Decrypt the sealed body into `self`'s sensitive fields. Caller must have + /// already run `deserialize` (which fills the bootstrap fields and the + /// crypto-header) and obtained the matching DEK via the key-slot flow. + pub fn decrypt_body( + &mut self, + cipher: &crate::crypto::PageCipher, + raw: &[u8; PAGE_SIZE], + ) -> std::result::Result<(), crate::crypto::CryptoError> { + let base = Self::SEALED_BODY_OFFSET; + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(&raw[base..base + NONCE_LEN]); + let mut tag = [0u8; TAG_LEN]; + tag.copy_from_slice(&raw[base + NONCE_LEN..base + NONCE_LEN + TAG_LEN]); + let ct_len = u16::from_le_bytes( + raw[base + NONCE_LEN + TAG_LEN..base + NONCE_LEN + TAG_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let coff = base + NONCE_LEN + TAG_LEN + 2; + let ct = &raw[coff..cof_end(coff, ct_len)]; + let aad = self.sb_identity_aad(); + let body = cipher.open_body(&aad, &nonce, &tag, ct)?; + self.load_body(&body); + Ok(()) + } +} + +#[inline] +fn cof_end(start: usize, len: usize) -> usize { + start + len +} +``` + +Add `use crate::crypto::{NONCE_LEN, TAG_LEN};` to the file's imports. + +In `deserialize` (line ~314), branch on the crypto-header: read it first, and when present leave the sensitive fields zeroed for `decrypt_body` to fill: +```rust + pub fn deserialize(buf: &[u8; PAGE_SIZE]) -> Option { + validate(buf).ok()?; + let encryption = crypto_header::CryptoHeader::deserialize(buf); + if encryption.is_some() { + // Encrypted DB: only the bootstrap fields are in cleartext. The + // sensitive fields stay zero until the caller supplies the DEK and + // calls decrypt_body. named_roots default to EMPTY. + let superblock_count = u32::from_le_bytes( + buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4].try_into().unwrap(), + ); + return Some(Superblock { + magic: u32::from_le_bytes(buf[0..4].try_into().unwrap()), + format_version: u32::from_le_bytes(buf[4..8].try_into().unwrap()), + txn_counter: u64::from_le_bytes(buf[8..16].try_into().unwrap()), + root_handle_table_page: 0, + root_freemap_page: 0, + total_pages: 0, + next_handle: 0, + page_size: u32::from_le_bytes(buf[48..52].try_into().unwrap()), + named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT], + superblock_count, + root_membership_index_page: 0, + freemap_depth: 0, + encryption, + }); + } + // ... existing plaintext path, adding `encryption: None,` to the returned struct ... + } +``` + +Add `encryption: None` to: `new_empty` (line ~449), every `Superblock { .. }` literal in `mod.rs`'s tests, and the literal in `src/transaction/commit.rs:116`. + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test superblock` Expected: PASS (plaintext round-trip tests still pass; new encrypted tests pass) +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(superblock): DEK-sealed body and Superblock.encryption field" +``` + +--- + +### Task 2.3: `create_new` with a key — generate DEK, derive KEK, wrap into slot 0, MAJOR=2 + +**Files:** +- Modify: `src/superblock/mod.rs` (add `new_empty_encrypted(superblock_count, header) -> Superblock` helper that sets `encryption: Some(header)` and the MAJOR=2 `format_version`) +- Modify: `src/transaction/recovery.rs:32` (change `create_new` to `create_new(mut cache: PageCache, superblock_count: u32, key: Option)`; encrypted path generates DEK + slot-0 salt, derives KEK, wraps DEK, writes encrypted superblocks, and stores the resulting `PageCipher`/`Dek` on the manager) +- Modify: `src/transaction/mod.rs:146` (add `cipher: Option` field to `TransactionManager`, held for the session) +- Modify: `src/lib.rs:346` and `:404` (pass `options.encryption_key.clone()` / `None` to `create_new`) +- Modify: `src/lib.rs` Options (add `pub(crate) encryption_key: Option` + builder setter `with_encryption_key`) +- Test: `tests/encryption_create.rs` (integration) + +**Interfaces:** +- Consumes (Phase 1): `crypto::{Key, random_dek, random_array, derive_kek, wrap_dek, KdfId, Argon2Params, PageCipher, SALT_LEN, NONCE_LEN}`. +- Produces: `TransactionManager.cipher: Option`, `Options::with_encryption_key`, the create-time key flow. + +A new MAJOR version constant is needed. Add to `src/page.rs` near `FORMAT_MAJOR_VERSION`: +```rust +/// MAJOR version stamped into an ENCRYPTED database's superblock. The bump from +/// 1 -> 2 hard-rejects old binaries (which gate on FORMAT_MAJOR_VERSION == 1). +pub const FORMAT_MAJOR_VERSION_ENCRYPTED: u16 = 2; +pub fn format_version_encrypted() -> u32 { + ((FORMAT_MAJOR_VERSION_ENCRYPTED as u32) << 16) | (FORMAT_MINOR_VERSION as u32) +} +``` + +- [ ] **Step 1: Write the failing test** +```rust +// tests/encryption_create.rs +use chisel::crypto::Key; +use chisel::{Chisel, Options}; +use std::fs; +use zeroize::Zeroizing; + +#[test] +fn create_encrypted_db_writes_plaintext_header_and_hidden_named_roots() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("enc.chisel"); + + let key = Key::Raw(Zeroizing::new(vec![0xABu8; 32])); + let opts = Options::builder() + .with_encryption_key(key) + .build(); + { + let mut db = Chisel::open(&path, opts).expect("create encrypted db"); + db.set_root_name("secret-table", 1).unwrap(); // exercises named_roots + db.commit().unwrap(); + } + + let bytes = fs::read(&path).unwrap(); + // Page 0: MAJOR version must be 2 (encrypted), upper 16 bits of bytes 4..8. + let fv = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); + assert_eq!(fv >> 16, 2, "encrypted DB must stamp MAJOR=2"); + // The named-root name "secret-table" must NOT appear anywhere in page 0. + assert!( + !bytes[0..8192].windows(12).any(|w| w == b"secret-table"), + "named root leaked in cleartext" + ); + // Crypto-header algorithm byte at offset 324 must be 1. + assert_eq!(bytes[324], 1, "crypto-header algorithm byte not set"); +} +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test --test encryption_create` Expected: FAIL (`with_encryption_key` / encrypted create path not implemented) +- [ ] **Step 3: Implement** + +`new_empty_encrypted` in `src/superblock/mod.rs`: +```rust + /// Like `new_empty` but for an encrypted database: stamps the MAJOR=2 + /// encrypted format version and attaches the crypto-header. Sensitive + /// fields are the same fresh-DB defaults; they get sealed by + /// `serialize_encrypted`. + pub fn new_empty_encrypted(superblock_count: u32, header: CryptoHeader) -> Superblock { + let mut sb = Superblock::new_empty(superblock_count); + sb.format_version = page::format_version_encrypted(); + sb.encryption = Some(header); + sb + } +``` + +In `src/transaction/recovery.rs`, change the `create_new` signature and add the encrypted bank-write path: +```rust + pub fn create_new( + mut cache: PageCache, + superblock_count: u32, + key: Option, + ) -> Result { + assert!( + (2..=MAX_SUPERBLOCKS).contains(&superblock_count), + "superblock_count {superblock_count} out of supported range 2..=16" + ); + + // Build the per-session cipher up front for an encrypted DB: a fresh + // random DEK is sealed into slot 0 under a KEK derived from `key`. + let cipher = match key { + None => None, + Some(k) => Some(build_create_cipher(&k, &mut cache, superblock_count)?), + }; + + // ... existing roots/manager construction, but the per-slot write loop + // branches on `cipher`: + if let Some(ref c) = cipher { + let header = c.header_for_create.clone(); // see build_create_cipher + let mut sb = Superblock::new_empty_encrypted(superblock_count, header); + for i in 0..superblock_count { + sb.txn_counter = (superblock_count - 1 - i) as u64; + let buf = sb.cipher.serialize_encrypted(...); // see note + cache.io_mut().write_page(i as u64, &buf)?; + } + } else { + let mut sb = Superblock::new_empty(superblock_count); + for i in 0..superblock_count { + sb.txn_counter = (superblock_count - 1 - i) as u64; + let buf = sb.serialize(); + cache.io_mut().write_page(i as u64, &buf)?; + } + } + cache.io_mut().fsync()?; + cache.set_next_page_id(superblock_count as u64); + // ... existing Roots + TransactionManager construction, adding + // `cipher: cipher.map(|c| c.page_cipher),` to the struct ... + } +``` + +Because `build_create_cipher` must return both the `PageCipher` (for the session) and the `CryptoHeader` (slot-0 wrap) so each slot serializes identically, define a small local helper at the bottom of `recovery.rs`: +```rust +/// Build the session PageCipher for a freshly-created encrypted DB: generate a +/// random DEK + slot-0 salt, derive the KEK from the client key, wrap the DEK +/// into slot 0, and assemble the crypto-header. Returns the live PageCipher and +/// the header to stamp into every superblock slot. +struct CreateCrypto { + page_cipher: crate::crypto::PageCipher, + header: crate::superblock::CryptoHeader, +} + +fn build_create_cipher( + key: &crate::crypto::Key, + _cache: &mut PageCache, + _superblock_count: u32, +) -> Result { + use crate::crypto::{ + derive_kek, random_array, random_dek, wrap_dek, Argon2Params, KdfId, NONCE_LEN, SALT_LEN, + }; + use crate::superblock::{CryptoHeader, KeySlot, ALGO_XCHACHA20POLY1305, KEY_SLOT_COUNT}; + + let dek = random_dek(); + let salt: [u8; SALT_LEN] = random_array(); + let wrap_nonce: [u8; NONCE_LEN] = random_array(); + // KDF choice: a Raw key uses HKDF; a Passphrase uses Argon2id. + let (kdf, params) = match key { + crate::crypto::Key::Raw(_) => (KdfId::Hkdf, Argon2Params::default()), + crate::crypto::Key::Passphrase(_) => (KdfId::Argon2id, Argon2Params::default()), + }; + let kek = derive_kek(key, kdf, &salt, ¶ms) + .map_err(|e| ChiselError::from(e))?; // CryptoError -> InvalidEncryptionKey via the From impl + + let mut slot = KeySlot::EMPTY; + slot.state = 1; + slot.kdf_id = kdf as u8; + slot.argon2 = params; + slot.salt = salt; + slot.wrap_nonce = wrap_nonce; + let aad = slot.aad(); + let (wrapped, tag) = wrap_dek(&kek, &dek, &wrap_nonce, &aad); + slot.wrapped_dek = wrapped; + slot.wrap_tag = tag; + + let mut slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + slots[0] = slot; + let header = CryptoHeader { algorithm: ALGO_XCHACHA20POLY1305, stride: 8232, slots }; + + Ok(CreateCrypto { + page_cipher: crate::crypto::PageCipher::new(dek), + header, + }) +} +``` +(In the slot-write loop, build `let buf = sb.serialize_encrypted(&cipher.page_cipher);` — the `CreateCrypto` is in scope as `cipher` before being unpacked into the manager.) + +Add the `cipher` field to `TransactionManager` in `src/transaction/mod.rs` after `poisoned` (line ~217): +```rust + /// Per-session page cipher for an encrypted database. `None` for plaintext. + /// Holds the unwrapped DEK (zeroizing) for the life of the manager; reaches + /// the PageCache in Phase 3 for per-page seal/open. Set on both the create + /// path (fresh DEK) and the open path (DEK unwrapped from a key-slot). + cipher: Option, +``` +Set `cipher: None` in `open_existing` (Task 2.4 fills it) and the in-memory create path. + +Options in `src/lib.rs`: +```rust + /// Encryption key supplied at open/create. `Some` creates (or opens) an + /// encrypted database; `None` keeps the existing plaintext format. + pub(crate) encryption_key: Option, +``` +Builder setter: +```rust + pub fn with_encryption_key(mut self, key: crate::crypto::Key) -> Self { + self.encryption_key = Some(key); + self + } +``` +Pass it through at `src/lib.rs:346`: +```rust +TransactionManager::create_new(cache, options.superblock_count, options.encryption_key.clone())? +``` +and `None` at the in-memory create site (`:404`). + +Add a `From for ChiselError` conversion so the create/open and key paths can use `?` on crypto calls. The encryption error variants already exist because **Task 4.1 is implemented first** (see the plan header's execution-order exception) — no placeholder variant is needed: +```rust +impl From for ChiselError { + // A CryptoError reaching the engine through `?` in the create/open/key-management + // paths is always a key-or-KDF problem on intact on-disk data, so it maps to the + // operational InvalidEncryptionKey. The page-read path (Phase 3) does NOT use this + // blanket conversion — it maps decrypt failures explicitly to the fatal + // ChiselError::DecryptionFailed { page_id } via `.map_err(...)`. The operational + // cases that are NOT CryptoError-derived (no key supplied for an encrypted DB; a + // key supplied for a plaintext DB) are returned explicitly as NoEncryptionKey / + // EncryptionNotSupported at their decision sites. + fn from(_: crate::crypto::CryptoError) -> Self { + ChiselError::InvalidEncryptionKey + } +} +``` + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test --test encryption_create` Expected: PASS +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(engine): create encrypted database — wrap DEK into slot 0, stamp MAJOR=2" +``` + +--- + +### Task 2.4: `open_existing` with a key — try each active slot, unwrap DEK, decrypt body, validate + +**Files:** +- Modify: `src/transaction/recovery.rs:136` (change `open_existing` to `open_existing(mut cache: PageCache, key: Option)`; after `select()` picks the winner, if it carries a crypto-header, try each active key-slot to unwrap the DEK, then `decrypt_body`; wrong/missing/spurious key → typed error) +- Modify: `src/lib.rs:344` (pass `options.encryption_key.clone()` to `open_existing`) +- Test: `tests/encryption_open.rs` (integration) + +**Interfaces:** +- Consumes (Phase 1): `crypto::{Key, derive_kek, unwrap_dek, KdfId, PageCipher}`. +- Consumes (Task 2.2/2.3): `Superblock.encryption`, `decrypt_body`, `new_empty_encrypted`. +- Produces: the open-time key flow; `TransactionManager.cipher` populated on the encrypted-open path. + +Wrong key → `InvalidEncryptionKey` (operational, NOT fatal — must not poison). Missing key on an encrypted DB → `NoEncryptionKey`. Key supplied for a plaintext DB → `EncryptionNotSupported`. All three are defined in **Task 4.1 (implemented first)**, so they are used directly here; the test asserts the open simply `is_err()` for each, so it does not depend on the exact variant. + +- [ ] **Step 1: Write the failing test** +```rust +// tests/encryption_open.rs +use chisel::crypto::Key; +use chisel::{Chisel, Options}; +use zeroize::Zeroizing; + +fn raw_key(b: u8) -> Key { + Key::Raw(Zeroizing::new(vec![b; 32])) +} + +#[test] +fn round_trip_open_with_correct_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("e.chisel"); + { + let mut db = Chisel::open( + &path, + Options::builder().with_encryption_key(raw_key(0x11)).build(), + ).unwrap(); + let h = db.insert(b"hello world").unwrap(); + db.commit().unwrap(); + assert_eq!(db.read(h).unwrap().as_deref(), Some(&b"hello world"[..])); + } + // Reopen with the SAME key: data must come back. + { + let db = Chisel::open( + &path, + Options::builder() + .with_encryption_key(raw_key(0x11)) + .create_if_missing(false) + .build(), + ).unwrap(); + // first handle minted is 1 + assert_eq!(db.read(1).unwrap().as_deref(), Some(&b"hello world"[..])); + } +} + +#[test] +fn wrong_key_is_operational_error_not_panic() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("e.chisel"); + { + let mut db = Chisel::open( + &path, + Options::builder().with_encryption_key(raw_key(0x11)).build(), + ).unwrap(); + db.commit().unwrap(); + } + let err = Chisel::open( + &path, + Options::builder() + .with_encryption_key(raw_key(0x22)) + .create_if_missing(false) + .build(), + ); + assert!(err.is_err(), "wrong key must fail to open"); +} + +#[test] +fn missing_key_on_encrypted_db_errors() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("e.chisel"); + { + let mut db = Chisel::open( + &path, + Options::builder().with_encryption_key(raw_key(0x11)).build(), + ).unwrap(); + db.commit().unwrap(); + } + let err = Chisel::open( + &path, + Options::builder().create_if_missing(false).build(), + ); + assert!(err.is_err(), "encrypted DB opened without a key must fail"); +} + +#[test] +fn key_on_plaintext_db_errors() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("p.chisel"); + { + let mut db = Chisel::open(&path, Options::default()).unwrap(); + db.commit().unwrap(); + } + let err = Chisel::open( + &path, + Options::builder() + .with_encryption_key(raw_key(0x11)) + .create_if_missing(false) + .build(), + ); + assert!(err.is_err(), "supplying a key to a plaintext DB must fail"); +} +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test --test encryption_open` Expected: FAIL (open key flow not implemented) +- [ ] **Step 3: Implement** + +In `open_existing`, after the `select()` winner is chosen and BEFORE the page-size / total-pages checks that read sensitive fields, add the key-flow branch. The version gate at line ~171 must accept MAJOR 2 when a key is present: +```rust + pub fn open_existing( + mut cache: PageCache, + key: Option, + ) -> Result { + // ... existing candidate read + select() unchanged ... + let mut sb = Superblock::select(&candidates).ok_or_else(|| ChiselError::CorruptSuperblock { + defects: Superblock::diagnose(&candidates), + })?; + + // Encryption gate. The winning slot's crypto-header (already parsed by + // deserialize into sb.encryption) tells us whether the DB is encrypted. + // Mismatches between "DB encrypted?" and "key supplied?" are operational + // open errors, not torn-slot signals. + let cipher = match (&sb.encryption, &key) { + (None, None) => None, + (Some(_), None) => return Err(ChiselError::NoEncryptionKey), + (None, Some(_)) => return Err(ChiselError::EncryptionNotSupported), + (Some(header), Some(k)) => { + // The winning slot's raw bytes are needed to decrypt the body. + let raw = candidates[(sb.txn_counter % sb.superblock_count as u64) as usize]; + let dek = unwrap_first_matching_slot(header, k)?; + let cipher = crate::crypto::PageCipher::new(dek); + sb.decrypt_body(&cipher, &raw) + .map_err(|_| ChiselError::InvalidEncryptionKey)?; + Some(cipher) + } + }; + + // Version gate: plaintext DBs must be MAJOR==1; encrypted DBs MAJOR==2. + let expected_major = if sb.encryption.is_some() { + page::FORMAT_MAJOR_VERSION_ENCRYPTED + } else { + page::FORMAT_MAJOR_VERSION + }; + if page::format_major(sb.format_version) != expected_major { + return Err(ChiselError::UnsupportedFormatVersion { + found: sb.format_version, + expected: sb.format_version, // expected-major already implied by encryption flag + }); + } + // ... existing page_size check, minor write-gate, total_pages check, + // roots construction, handle-table/membership recovery unchanged + // (they now read the DECRYPTED sb fields) ... + + Ok(TransactionManager { + // ... existing fields ... + cipher, + // ... + }) + } +``` + +Slot-trial helper at the bottom of `recovery.rs`: +```rust +/// Try every ACTIVE key-slot in turn: derive the KEK from `key` + the slot's +/// salt/params, attempt to unwrap the DEK. The first slot whose tag verifies +/// yields the DEK. If none verify, the key is wrong for this DB. +/// +/// Trying every slot (rather than a slot index hint) is what makes multi-key +/// support work: a DB may have the same DEK wrapped under several KEKs, and the +/// caller's key matches exactly one of them. +fn unwrap_first_matching_slot( + header: &crate::superblock::CryptoHeader, + key: &crate::crypto::Key, +) -> Result { + use crate::crypto::{derive_kek, unwrap_dek, KdfId}; + + for slot in header.slots.iter().filter(|s| s.is_active()) { + let kdf = match slot.kdf_id { + 1 => KdfId::Hkdf, + 2 => KdfId::Argon2id, + _ => continue, // unknown KDF id: skip, treat as non-matching + }; + let kek = match derive_kek(key, kdf, &slot.salt, &slot.argon2) { + Ok(k) => k, + Err(_) => continue, + }; + let aad = slot.aad(); + if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.wrap_tag, &slot.wrap_nonce, &aad) + { + return Ok(dek); + } + } + Err(ChiselError::InvalidEncryptionKey) +} +``` + +The `NoEncryptionKey`, `InvalidEncryptionKey`, and `EncryptionNotSupported` variants are already defined (operational, `is_fatal() == false`) by **Task 4.1, implemented first** — do NOT re-add them here (that would duplicate the enum arms). Just wire `options.encryption_key.clone()` into the `open_existing` call at `src/lib.rs:344`. + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test --test encryption_open` Expected: PASS +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(engine): open encrypted database — unwrap DEK from key-slot, decrypt body" +``` + +--- + +### Task 2.5: thread the DEK to TransactionManager held zeroizing for the session + full-suite green + +**Files:** +- Modify: `src/transaction/mod.rs` (confirm `cipher: Option` placed; `PageCipher` holds the `Dek` which is `Zeroizing` per Phase 1, so dropping the manager zeroizes the key — no extra `Drop` needed) +- Modify: any remaining `create_new(` / `open_existing(` call sites the compiler flags (in-memory paths in `lib.rs`, plus `#[cfg(test)]` callers in `src/transaction/tests` and integration tests) to pass the new `None` argument +- Test: `#[cfg(test)] mod` accessor test in `src/transaction/mod.rs` + full suite + +**Interfaces:** +- Consumes: everything above. +- Produces: `TransactionManager` consistently constructed with `cipher`; a session-held zeroizing DEK reachable for Phase 3's PageCache wiring. + +- [ ] **Step 1: Write the failing test** +```rust +// in src/transaction/mod.rs tests module (or a small test in recovery.rs) +#[test] +fn encrypted_manager_holds_session_cipher() { + use crate::crypto::Key; + use crate::page_cache::PageCache; + use crate::page_io::PageIo; + use zeroize::Zeroizing; + + let io = PageIo::open_in_memory(); + let cache = PageCache::new_for_test(io); // existing in-memory test ctor + let key = Key::Raw(Zeroizing::new(vec![0x5Au8; 32])); + let txm = TransactionManager::create_new(cache, 2, Some(key)).unwrap(); + assert!(txm.cipher.is_some(), "encrypted create must retain a session cipher"); +} + +#[test] +fn plaintext_manager_has_no_cipher() { + use crate::page_cache::PageCache; + use crate::page_io::PageIo; + + let io = PageIo::open_in_memory(); + let cache = PageCache::new_for_test(io); + let txm = TransactionManager::create_new(cache, 2, None).unwrap(); + assert!(txm.cipher.is_none()); +} +``` +(If `PageCache::new_for_test`/`PageIo::open_in_memory` differ from the actual in-memory test constructors, substitute the real ones the existing `mod tests` already uses — grep `create_new(` in `src/transaction/tests` for the established pattern and reuse it verbatim.) + +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test encrypted_manager_holds_session_cipher` Expected: FAIL until all call sites updated and the field is wired +- [ ] **Step 3: Implement** +Update every `TransactionManager::create_new(cache, N)` call to `create_new(cache, N, None)` and every `open_existing(cache)` to `open_existing(cache, None)` flagged by the compiler (in-memory paths in `lib.rs` lines ~404 and the transaction test module). The `cipher` field is set on each constructor as shown in Tasks 2.3/2.4. No `Drop` impl is needed: `PageCipher` owns a `Dek(Zeroizing<[u8; 32]>)` (Phase 1), so the key is zeroed on the manager's drop automatically. +- [ ] **Step 4: Run the full suite** +Run: `cargo test` Expected: PASS (all existing plaintext tests, plus the new encryption create/open/manager tests; integration tests in `tests/` run because we use plain `cargo test`) +Run: `cargo clippy --all-targets -- -D warnings` Expected: clean +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(engine): hold session DEK (zeroizing) on TransactionManager; green full suite" +``` + +--- + +Notes on deliberate simplifications (`ponytail`): only **slot 0** is populated at create time — multi-key add/rotate is a separate later feature, not Phase 2 scope; the open path already trial-decrypts ALL active slots, so adding more slots later needs no open-path change. The encryption error variants are defined in **Task 4.1, which is implemented first** (see the plan header's execution-order exception), so the create/open code returns the real variants directly — `NoEncryptionKey` (encrypted DB, no key), `InvalidEncryptionKey` (key unwraps no slot), `EncryptionNotSupported` (key supplied for a plaintext DB) — and the `From` conversion maps to `InvalidEncryptionKey`. The integration tests here assert `is_err()` rather than the exact variant, keeping them robust to wording. + +--- + +## Phase 3: Stride-aware page_io, page-cache seal/open orchestration, spillway + +### Task 3.1: Make `page_io` stride-aware (crypto-agnostic on-disk unit) + +**Files:** +- Modify: `src/page_io.rs:67` (PageIo struct — add `stride` field), `src/page_io.rs:130` (open seed), `src/page_io.rs:242` (read_page), `src/page_io.rs:283` (write_page), `src/page_io.rs:411` (set_page_count) +- Test: `src/page_io.rs` (`#[cfg(test)] mod stride_tests` in the same file) + +**Interfaces:** +- Consumes: `crate::crypto::ENC_PAGE_SIZE` (Phase 1, `= 8232`) +- Produces: + - `pub fn read_page_unit(&mut self, page_id: u64) -> Result>` — returns the on-disk blob of `stride` bytes + - `pub fn write_page_unit(&mut self, page_id: u64, blob: &[u8]) -> Result<()>` — writes a `stride`-byte blob + - `pub fn set_stride(&mut self, stride: usize)` / `pub fn stride(&self) -> usize` + - The existing `read_page`/`write_page` stay (plaintext path = stride `PAGE_SIZE`); offset becomes `page_id * stride`. + +Note: page_io stays crypto-agnostic. It only knows a `stride` (the on-disk unit size). The cache passes already-sealed blobs in; page_io never seals. `stride` defaults to `PAGE_SIZE`; the engine calls `set_stride(ENC_PAGE_SIZE)` when the superblock says encrypted. The page-count cache now counts *units* of `stride` bytes, not `PAGE_SIZE` — `page_id * stride` is the only offset math that exists, so the seed at open must divide by `stride`, and `set_stride` must be called BEFORE the first read on an encrypted DB (the engine does this right after reading page 0's plaintext bootstrap header). + +- [ ] **Step 1: Write the failing test** +```rust +#[cfg(test)] +mod stride_tests { + use super::*; + use crate::crypto::ENC_PAGE_SIZE; + + // Offset math must use the on-disk stride, not PAGE_SIZE. With an 8232-byte + // stride, page 2's blob lives at byte 16464, and page_count is reported in + // stride-units. In-memory backing so the test is filesystem-free. + #[test] + fn stride_8232_offsets_and_unit_roundtrip() { + let mut io = PageIo::open_in_memory().unwrap(); + io.set_stride(ENC_PAGE_SIZE); + assert_eq!(io.stride(), ENC_PAGE_SIZE); + + // A distinct 8232-byte blob per page id. + let mut blob0 = vec![0u8; ENC_PAGE_SIZE]; + blob0[0] = 0xA0; + blob0[ENC_PAGE_SIZE - 1] = 0x0A; + let mut blob2 = vec![0u8; ENC_PAGE_SIZE]; + blob2[0] = 0xC2; + blob2[ENC_PAGE_SIZE - 1] = 0x2C; + + io.write_page_unit(0, &blob0).unwrap(); + io.write_page_unit(2, &blob2).unwrap(); // page 1 zero-filled by growth + + // page_count is in stride units: writing page 2 extends to 3. + assert_eq!(io.page_count().unwrap(), 3); + + assert_eq!(io.read_page_unit(0).unwrap(), blob0); + assert_eq!(io.read_page_unit(2).unwrap(), blob2); + // The zero-filled gap page reads back as all zeros. + assert_eq!(io.read_page_unit(1).unwrap(), vec![0u8; ENC_PAGE_SIZE]); + } + + // The plaintext stride (default) keeps PAGE_SIZE offset math intact. + #[test] + fn default_stride_is_page_size() { + let io = PageIo::open_in_memory().unwrap(); + assert_eq!(io.stride(), PAGE_SIZE); + } + + // A blob whose length != stride is a caller bug, not silent truncation. + #[test] + fn write_unit_wrong_length_is_invalid() { + let mut io = PageIo::open_in_memory().unwrap(); + io.set_stride(ENC_PAGE_SIZE); + let short = vec![0u8; PAGE_SIZE]; // wrong: 8192 != 8232 + assert!(io.write_page_unit(0, &short).is_err()); + } +} +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test stride_8232_offsets_and_unit_roundtrip` Expected: FAIL (no `set_stride`/`read_page_unit`/`write_page_unit` yet) +- [ ] **Step 3: Implement** + +The `Memory` backing currently stores `Vec<[u8; PAGE_SIZE]>`. A stride-aware unit store needs variable-length rows, so switch it to a flat `Vec` addressed by `page_id * stride`. Change the struct and the three I/O entry points. Replace `Backing::Memory { pages: Vec<[u8; PAGE_SIZE]> }` with a flat byte vec; the existing `read_page`/`write_page` become thin wrappers that copy into/out of a `PAGE_SIZE` array via the unit functions when `stride == PAGE_SIZE`. + +```rust +// In the struct (src/page_io.rs:67), add after `read_only: bool,`: + // On-disk unit size in bytes. PAGE_SIZE for a plaintext DB; ENC_PAGE_SIZE + // (8232 = 8192 ct + 16 tag + 24 nonce) for an encrypted DB. This module is + // crypto-agnostic: it only moves `stride`-byte blobs and computes + // offset = page_id * stride. The engine sets this to ENC_PAGE_SIZE right + // after reading page 0's plaintext bootstrap header on an encrypted open, + // BEFORE any other page is read. page_count is reported in stride-units. + stride: usize, +``` + +```rust +// Backing::Memory becomes flat bytes (src/page_io.rs:44): + Memory { bytes: Vec }, +``` + +```rust +// Seed `stride` in open() (after line 124's `let mut file = ...; Self::try_lock`) +// and recompute the page-count seed against PAGE_SIZE (the default stride at +// open — set_stride happens later, and on a stride change the engine reseeds +// via set_stride below). Replace the PageIo construction at src/page_io.rs:132: + let initial_len = file.seek(SeekFrom::End(0))?; + let initial_page_count = initial_len / PAGE_SIZE as u64; + Ok(PageIo { + backing: Backing::File { file }, + read_only, + stride: PAGE_SIZE, + fsync_calls: Cell::new(0), + cached_page_count: Cell::new(initial_page_count), + #[cfg(test)] + fault: Cell::new(Fault::None), + }) +``` + +```rust +// open_in_memory() construction (src/page_io.rs:151): + Ok(PageIo { + backing: Backing::Memory { bytes: Vec::new() }, + read_only: false, + stride: PAGE_SIZE, + fsync_calls: Cell::new(0), + cached_page_count: Cell::new(0), + #[cfg(test)] + fault: Cell::new(Fault::None), + }) +``` + +```rust +// New accessors + setter. Place after force_read_only (src/page_io.rs:179). + /// On-disk unit size in bytes (PAGE_SIZE plaintext, ENC_PAGE_SIZE encrypted). + pub fn stride(&self) -> usize { + self.stride + } + + /// Set the on-disk stride and re-seed the page-count cache against the new + /// unit size. Must be called BEFORE the first unit read on an encrypted DB + /// (the engine does this immediately after reading page 0's plaintext + /// bootstrap header). Re-seeds from the true file length so page_count is + /// reported in the new stride-units. + pub fn set_stride(&mut self, stride: usize) { + self.stride = stride; + let len = match &mut self.backing { + Backing::File { file } => file.seek(SeekFrom::End(0)).unwrap_or(0), + Backing::Memory { bytes } => bytes.len() as u64, + }; + self.cached_page_count.set(len / stride as u64); + } + + /// Read the raw on-disk unit (stride bytes) for `page_id`. Crypto-agnostic: + /// for an encrypted DB this returns the sealed `ct‖tag‖nonce` blob, which the + /// PageCache then hands to PageCipher::open. For a plaintext DB stride == + /// PAGE_SIZE and the blob is the page image itself. + pub fn read_page_unit(&mut self, page_id: u64) -> Result> { + let page_count = self.page_count()?; + if page_id >= page_count { + return Err(ChiselError::InvalidPageId { page_id }); + } + #[cfg(test)] + if self.fault.get() == Fault::FailReadPage(page_id) { + self.fault.set(Fault::None); + return Err(ChiselError::IoError(std::io::Error::other( + "fault-injected read failure", + ))); + } + let stride = self.stride; + match &mut self.backing { + Backing::File { file } => { + let offset = page_id * stride as u64; + file.seek(SeekFrom::Start(offset))?; + let mut buf = vec![0u8; stride]; + file.read_exact(&mut buf)?; + Ok(buf) + } + Backing::Memory { bytes } => { + let off = (page_id * stride as u64) as usize; + Ok(bytes[off..off + stride].to_vec()) + } + } + } + + /// Write a raw on-disk unit (must be exactly `stride` bytes). Past-EOF + /// writes extend the file (POSIX); intermediate units are zero-filled. + pub fn write_page_unit(&mut self, page_id: u64, blob: &[u8]) -> Result<()> { + if self.read_only { + return Err(ChiselError::ReadOnlyMode); + } + if blob.len() != self.stride { + return Err(ChiselError::IoError(std::io::Error::other(format!( + "page unit length {} != stride {}", + blob.len(), + self.stride + )))); + } + #[cfg(test)] + if self.fault.get() == Fault::FailWritePage(page_id) { + self.fault.set(Fault::None); + return Err(ChiselError::IoError(std::io::Error::other( + "fault-injected write failure", + ))); + } + let stride = self.stride; + match &mut self.backing { + Backing::File { file } => { + let offset = page_id * stride as u64; + file.seek(SeekFrom::Start(offset))?; + file.write_all(blob)?; + } + Backing::Memory { bytes } => { + let off = (page_id * stride as u64) as usize; + let needed = off + stride; + if bytes.len() < needed { + bytes.resize(needed, 0); + } + bytes[off..off + stride].copy_from_slice(blob); + } + } + let needed = page_id + 1; + if needed > self.cached_page_count.get() { + self.cached_page_count.set(needed); + } + Ok(()) + } +``` + +```rust +// Rewrite read_page (src/page_io.rs:242) and write_page (src/page_io.rs:283) as +// thin PAGE_SIZE-typed wrappers over the unit functions. They are only valid +// when stride == PAGE_SIZE (the plaintext path); the encrypted path goes through +// the *_unit functions directly from the cache. debug_assert pins the contract. + pub fn read_page(&mut self, page_id: u64) -> Result<[u8; PAGE_SIZE]> { + debug_assert_eq!(self.stride, PAGE_SIZE, "read_page on an encrypted stride; use read_page_unit"); + let blob = self.read_page_unit(page_id)?; + let mut buf = [0u8; PAGE_SIZE]; + buf.copy_from_slice(&blob); + Ok(buf) + } + + pub fn write_page(&mut self, page_id: u64, buf: &[u8; PAGE_SIZE]) -> Result<()> { + debug_assert_eq!(self.stride, PAGE_SIZE, "write_page on an encrypted stride; use write_page_unit"); + self.write_page_unit(page_id, buf) + } +``` + +```rust +// set_page_count (src/page_io.rs:411): file length is in stride-bytes now. + pub fn set_page_count(&mut self, n: u64) -> Result<()> { + if self.read_only { + return Err(ChiselError::ReadOnlyMode); + } + let stride = self.stride; + match &mut self.backing { + Backing::File { file } => { + file.set_len(n * stride as u64)?; + } + Backing::Memory { bytes } => { + bytes.resize((n * stride as u64) as usize, 0); + } + } + self.cached_page_count.set(n); + Ok(()) + } +``` + +Note: the existing memory-backing tests construct pages as `[u8; PAGE_SIZE]` and call `read_page`/`write_page` — those keep working unchanged because the default stride is `PAGE_SIZE` and the wrappers preserve the exact byte semantics. The `read_page` unchecked-index comment block at old lines 262-266 is removed with the rewrite. + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test stride_8232_offsets_and_unit_roundtrip default_stride_is_page_size write_unit_wrong_length_is_invalid` Expected: PASS. Also run `cargo test` to confirm the existing `read_page`/`write_page` memory + file tests still pass under the wrapper rewrite. +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(page_io): stride-aware raw on-disk page units (8232 encrypted, 8192 plaintext)" +``` + +--- + +### Task 3.2: Widen the spillway slot to carry a sealed blob + +**Files:** +- Modify: `src/spillway.rs:43` (SLOT_SIZE), `src/spillway.rs:52` (Spillway struct — add `payload_size`), `src/spillway.rs:88`/`123` (open_file / open_memory take payload size), `src/spillway.rs:184` (spill), `src/spillway.rs:273` (rehydrate), `src/spillway.rs:299`/`306`/`343` (slot_checksum / write_slot / read_slot) +- Test: `src/spillway.rs` (`#[cfg(test)] mod tests`, same file) + +**Interfaces:** +- Consumes: `crate::crypto::ENC_PAGE_SIZE` +- Produces: + - `Spillway::open_file(db_path, max_bytes, payload_size)` / `Spillway::open_memory(max_bytes, payload_size)` — `payload_size` is `PAGE_SIZE` (plaintext) or `ENC_PAGE_SIZE` (encrypted) + - `spill(&mut self, page_id, blob: &[u8])` / `rehydrate(&mut self, page_id) -> Result>` — carry a `payload_size`-byte blob (the sealed unit when encrypted) + - `pub const SLOT_HEADER_SIZE: usize = 16` unchanged; the slot is `SLOT_HEADER_SIZE + payload_size` at runtime. + +The spillway carries the SEALED blob for encrypted DBs (seal-once: the page was sealed when evicted, the spillway stores ciphertext, drain copies it verbatim). `slot_checksum` covers `page_id ‖ blob` so a torn spillway write is caught before the blob reaches the main file — this is the slot's integrity check, NOT the AEAD tag (which lives inside the sealed blob and is verified by PageCipher::open only on the cold main-file read path). + +- [ ] **Step 1: Write the failing test** +```rust + #[test] + fn wide_slot_round_trips_sealed_blob() { + use crate::crypto::ENC_PAGE_SIZE; + // payload_size = ENC_PAGE_SIZE: each slot carries an 8232-byte sealed + // blob plus the 16-byte header. Round-trip must return the exact bytes. + let slot = (SLOT_HEADER_SIZE + ENC_PAGE_SIZE) as u64; + let mut spw = Spillway::open_memory(slot * 4, ENC_PAGE_SIZE); + let mut blob = vec![0u8; ENC_PAGE_SIZE]; + blob[0] = 0xEE; + blob[ENC_PAGE_SIZE - 1] = 0x11; + spw.spill(7, &blob).unwrap(); + assert!(spw.is_resident(7)); + assert_eq!(spw.rehydrate(7).unwrap(), blob); + } + + #[test] + fn wide_slot_checksum_catches_tampered_payload() { + use crate::crypto::ENC_PAGE_SIZE; + let slot = (SLOT_HEADER_SIZE + ENC_PAGE_SIZE) as u64; + let mut spw = Spillway::open_memory(slot * 4, ENC_PAGE_SIZE); + spw.spill(7, &vec![0xAB; ENC_PAGE_SIZE]).unwrap(); + if let Backing::Memory { ref mut bytes } = spw.backing { + bytes[SLOT_HEADER_SIZE + 5] ^= 0x01; // flip a byte in the blob + } + assert!(matches!( + spw.rehydrate(7).unwrap_err(), + ChiselError::ChecksumMismatch { page_id: 7 } + )); + } +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test wide_slot_round_trips_sealed_blob wide_slot_checksum_catches_tampered_payload` Expected: FAIL (open_memory takes one arg; spill takes `[u8; PAGE_SIZE]`) +- [ ] **Step 3: Implement** + +The slot size is now runtime, not a `const`. Keep `SLOT_HEADER_SIZE`; remove the fixed `SLOT_SIZE` const (or keep it as the plaintext-default for any remaining caller) and compute `slot_size = SLOT_HEADER_SIZE + payload_size` from a new field. The `[u8; PAGE_SIZE]` signatures become `&[u8]` / `Vec`. + +```rust +// src/spillway.rs:43 — keep header const; SLOT_SIZE becomes the plaintext +// default (header + PAGE_SIZE) for callers/tests that still use it, but the +// live slot size is payload-driven via the struct field below. +pub const SLOT_HEADER_SIZE: usize = 16; +/// Plaintext-default slot size (header + PAGE_SIZE). Encrypted DBs use +/// `SLOT_HEADER_SIZE + ENC_PAGE_SIZE`; see `Spillway::payload_size`. +pub const SLOT_SIZE: usize = SLOT_HEADER_SIZE + PAGE_SIZE; +``` + +```rust +// Add to the Spillway struct (src/spillway.rs:52), after `max_bytes: u64,`: + /// Bytes per spilled payload: PAGE_SIZE plaintext, ENC_PAGE_SIZE encrypted. + /// On an encrypted DB the payload IS the sealed `ct‖tag‖nonce` blob — the + /// spillway stores ciphertext and drain copies it verbatim (seal-once). The + /// slot is `SLOT_HEADER_SIZE + payload_size` bytes; the per-slot XXH3 + /// checksum covers the payload, catching a torn spillway write before the + /// blob reaches the main file (distinct from the inner AEAD tag). + payload_size: usize, +``` + +```rust +// open_file (src/spillway.rs:88) and open_memory (src/spillway.rs:123) gain a +// payload_size param and store it. logical_bytes/spill/cap accounting all key +// off payload_size now. + pub fn open_file(db_path: &Path, max_bytes: u64, payload_size: usize) -> Result { + let mut path = db_path.as_os_str().to_owned(); + path.push(".spillway"); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&path) + .map_err(ChiselError::IoError)?; + Ok(Spillway { + backing: Backing::File { file }, + slots: HashMap::new(), + next_slot_index: 0, + max_bytes, + payload_size, + }) + } + + pub fn open_memory(max_bytes: u64, payload_size: usize) -> Spillway { + Spillway { + backing: Backing::Memory { bytes: Vec::new() }, + slots: HashMap::new(), + next_slot_index: 0, + max_bytes, + payload_size, + } + } +``` + +```rust +// logical_bytes (src/spillway.rs:158) — charge against payload_size, not +// PAGE_SIZE. The cap is still LIVE-residency based. + pub fn logical_bytes(&self) -> u64 { + self.slots.len() as u64 * self.payload_size as u64 + } +``` + +```rust +// spill (src/spillway.rs:184): blob is &[u8] of payload_size; the cap charge +// uses payload_size. + pub fn spill(&mut self, page_id: u64, blob: &[u8]) -> Result<()> { + debug_assert_eq!(blob.len(), self.payload_size, "spill blob != payload_size"); + let slot_index = if let Some(&existing) = self.slots.get(&page_id) { + existing + } else { + let post_write_bytes = (self.slots.len() as u64 + 1) * self.payload_size as u64; + if post_write_bytes > self.max_bytes { + return Err(ChiselError::SpillwayFull { + limit_bytes: self.max_bytes, + }); + } + let new_index = self.next_slot_index; + self.next_slot_index += 1; + self.slots.insert(page_id, new_index); + new_index + }; + write_slot(&mut self.backing, slot_index, page_id, blob, self.payload_size)?; + Ok(()) + } +``` + +```rust +// rehydrate (src/spillway.rs:273): returns Vec of payload_size. + pub fn rehydrate(&mut self, page_id: u64) -> Result> { + let slot_index = match self.slots.get(&page_id) { + Some(&i) => i, + None => return Err(ChiselError::InvalidPageId { page_id }), + }; + let (stored_page_id, stored_checksum, blob) = + read_slot(&mut self.backing, slot_index, self.payload_size)?; + if stored_page_id != page_id { + return Err(ChiselError::ChecksumMismatch { page_id }); + } + let computed = slot_checksum(page_id, &blob); + if computed != stored_checksum { + return Err(ChiselError::ChecksumMismatch { page_id }); + } + Ok(blob) + } +``` + +```rust +// Free functions: slot_checksum/write_slot/read_slot take &[u8] / payload_size +// and compute slot_size = SLOT_HEADER_SIZE + payload_size. +fn slot_checksum(page_id: u64, blob: &[u8]) -> u64 { + let mut hasher = xxhash_rust::xxh3::Xxh3::new(); + hasher.update(&page_id.to_le_bytes()); + hasher.update(blob); + hasher.digest() +} + +fn write_slot( + backing: &mut Backing, + slot_index: u64, + page_id: u64, + blob: &[u8], + payload_size: usize, +) -> Result<()> { + let slot_size = SLOT_HEADER_SIZE + payload_size; + let checksum = slot_checksum(page_id, blob); + let offset = slot_index * slot_size as u64; + let mut header = [0u8; SLOT_HEADER_SIZE]; + header[..8].copy_from_slice(&page_id.to_le_bytes()); + header[8..16].copy_from_slice(&checksum.to_le_bytes()); + match backing { + Backing::File { file } => { + file.seek(SeekFrom::Start(offset))?; + file.write_all(&header)?; + file.write_all(blob)?; + } + Backing::Memory { bytes } => { + let needed = (offset + slot_size as u64) as usize; + if bytes.len() < needed { + bytes.resize(needed, 0); + } + let off = offset as usize; + bytes[off..off + SLOT_HEADER_SIZE].copy_from_slice(&header); + bytes[off + SLOT_HEADER_SIZE..off + slot_size].copy_from_slice(blob); + } + } + Ok(()) +} + +fn read_slot( + backing: &mut Backing, + slot_index: u64, + payload_size: usize, +) -> Result<(u64, u64, Vec)> { + let slot_size = SLOT_HEADER_SIZE + payload_size; + let offset = slot_index * slot_size as u64; + let mut header = [0u8; SLOT_HEADER_SIZE]; + let mut blob = vec![0u8; payload_size]; + match backing { + Backing::File { file } => { + file.seek(SeekFrom::Start(offset))?; + file.read_exact(&mut header)?; + file.read_exact(&mut blob)?; + } + Backing::Memory { bytes } => { + let off = offset as usize; + if bytes.len() < off + slot_size { + return Err(ChiselError::IoError(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + format!("spillway memory backing too short for slot {slot_index}"), + ))); + } + header.copy_from_slice(&bytes[off..off + SLOT_HEADER_SIZE]); + blob.copy_from_slice(&bytes[off + SLOT_HEADER_SIZE..off + slot_size]); + } + } + let stored_page_id = u64::from_le_bytes(header[..8].try_into().unwrap()); + let stored_checksum = u64::from_le_bytes(header[8..16].try_into().unwrap()); + Ok((stored_page_id, stored_checksum, blob)) +} +``` + +Every existing spillway test that calls `open_memory(n)` / `spill(id, &page(b))` / compares `rehydrate` to `[u8; PAGE_SIZE]` must be updated: add `PAGE_SIZE` as the `payload_size` arg, pass `&page(b)[..]` (a slice), and compare against the page slice. The `page()` helper returns `[u8; PAGE_SIZE]`; `&page(0xAA)` coerces to `&[u8]`. `logical_bytes`/cap tests already use `PAGE_SIZE` arithmetic, which now equals `payload_size`. PageCache callers are updated in Task 3.3/3.4. + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test --test '*' wide_slot ; cargo test` Expected: PASS (run the full suite — the spillway and page_cache call sites are coupled). +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(spillway): runtime payload_size so slots carry sealed 8232-byte blobs" +``` + +--- + +### Task 3.3: Hold an `Option` in PageCache; seal/open on flush and cold load + +**Files:** +- Modify: `src/page_cache.rs:65` (PageCache struct — add `cipher` + helper), `src/page_cache.rs:161` (new — take cipher), `src/page_cache.rs:390` (flush Phase 1a write loop), `src/page_cache.rs:866` (load_page disk branch) +- Modify: `src/spillway.rs` ensure_spillway call sites (`src/page_cache.rs:1085`) to pass `payload_size` +- Test: `src/page_cache.rs` (`#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes (Phase 1): `crate::crypto::{PageCipher, ENC_PAGE_SIZE, CryptoError}`; `PageCipher::seal(page_id, &[u8;8192]) -> [u8; ENC_PAGE_SIZE]`; `PageCipher::open(page_id, &[u8;ENC_PAGE_SIZE]) -> Result<[u8;8192], CryptoError>` +- Consumes (Phase 1 stride): `PageIo::{set_stride, stride, read_page_unit, write_page_unit}` +- Produces: a PageCache whose flush writes sealed units and whose cold load opens+verifies them. `DecryptionFailed { page_id }` (fatal, added Phase 4) is surfaced when `PageCipher::open` fails. + +Seal-once invariant: the plaintext page is sealed exactly once — at flush (Phase 1a) and at evict-to-spillway (Task 3.4). The cache always holds PLAINTEXT (`[u8; PAGE_SIZE]`); the on-disk unit and the spillway hold ciphertext. Cold load opens the unit back to plaintext and runs `verify_checksum` on the plaintext (the page's internal XXH3), exactly as today. + +- [ ] **Step 1: Write the failing test** +```rust + use crate::crypto::{PageCipher, random_dek, ENC_PAGE_SIZE}; + + // Build an encrypted file-backed cache: stride set to ENC_PAGE_SIZE, a + // PageCipher installed. A page written, flushed, dropped from cache, and + // re-read must round-trip its plaintext through seal->disk->open. + fn fresh_encrypted_cache(max_pages: usize) -> (TempDir, PageCache) { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("test.chisel"); + let mut io = PageIo::open(&db_path, false).unwrap(); + io.set_stride(ENC_PAGE_SIZE); + let cache_max_bytes = max_pages as u64 * PAGE_SIZE as u64; + let mut cache = PageCache::new( + io, + cache_max_bytes, + 0, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + cache.set_cipher(PageCipher::new(random_dek())); + (dir, cache) + } + + #[test] + fn encrypted_page_round_trips_through_seal_open() { + let (_dir, mut cache) = fresh_encrypted_cache(8); + let pid = cache.new_page().unwrap(); + { + let buf = cache.get_mut(pid).unwrap(); + buf[0] = 0x9C; + buf[PAGE_SIZE - 1] = 0xC9; + // Stamp a valid internal checksum so cold-load verify_checksum passes. + crate::page::stamp_checksum(buf); + } + cache.flush().unwrap(); + // Force a cold read: drop from cache so load_page hits the disk unit. + cache.test_drop_from_cache(pid); + let read = cache.get(pid).unwrap(); + assert_eq!(read[0], 0x9C); + assert_eq!(read[PAGE_SIZE - 1], 0xC9); + } + + #[test] + fn tampered_ciphertext_surfaces_decryption_failed() { + let (_dir, mut cache) = fresh_encrypted_cache(8); + let pid = cache.new_page().unwrap(); + { + let buf = cache.get_mut(pid).unwrap(); + buf[10] = 0x42; + crate::page::stamp_checksum(buf); + } + cache.flush().unwrap(); + cache.test_drop_from_cache(pid); + // Corrupt one ciphertext byte in the on-disk unit (offset 0 is inside + // the 8192-byte ciphertext region of the 8232-byte stride). + { + let mut blob = cache.io_mut().read_page_unit(pid).unwrap(); + blob[0] ^= 0x01; + cache.io_mut().write_page_unit(pid, &blob).unwrap(); + } + let err = cache.get(pid).unwrap_err(); + assert!( + matches!(err, ChiselError::DecryptionFailed { page_id } if page_id == pid), + "expected DecryptionFailed, got {err:?}" + ); + } +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test encrypted_page_round_trips_through_seal_open tampered_ciphertext_surfaces_decryption_failed` Expected: FAIL (no `set_cipher`; flush/load_page not crypto-aware; `DecryptionFailed` is Phase 4 — if not yet merged, this task depends on Phase 4's error variant). +- [ ] **Step 3: Implement** + +```rust +// PageCache struct (src/page_cache.rs:65), add after `io: PageIo,`: + /// Page sealer/opener for encrypted DBs; None for plaintext. When Some, the + /// cache holds PLAINTEXT in `entries` but writes the SEALED on-disk unit + /// (via io.write_page_unit) and opens the sealed unit on cold load. Seal + /// happens exactly once per write (flush Phase 1a, evict-to-spillway); the + /// spillway and main file carry ciphertext, drain copies it verbatim. The + /// engine must call io.set_stride(ENC_PAGE_SIZE) when installing a cipher. + cipher: Option, +``` + +```rust +// PageCache::new (src/page_cache.rs:170): seed cipher: None in the struct +// literal (after `io,`): + io, + cipher: None, +``` + +```rust +// New setter, after set_drain_insertion (src/page_cache.rs:832): + /// Install the page cipher (encrypted DBs). The caller MUST have already + /// called `self.io_mut().set_stride(ENC_PAGE_SIZE)` so the on-disk unit math + /// matches the sealed blob size. Set once at open after the DEK is unwrapped. + pub fn set_cipher(&mut self, cipher: crate::crypto::PageCipher) { + self.cipher = Some(cipher); + } + + /// Seal a plaintext page to its on-disk unit and write it. Seal-once entry + /// point shared by flush Phase 1a. For a plaintext DB (no cipher) this is a + /// straight `write_page_unit` of the page image. + fn write_sealed(&mut self, page_id: u64, plaintext: &[u8; PAGE_SIZE]) -> Result<()> { + match &self.cipher { + Some(c) => { + let blob = c.seal(page_id, plaintext); + self.io.write_page_unit(page_id, &blob) + } + None => self.io.write_page_unit(page_id, plaintext), + } + } +``` + +```rust +// flush Phase 1a write loop (src/page_cache.rs:410-418): route through +// write_sealed instead of io.write_page. entry.buf is plaintext. + for &page_id in &dirty_scratch { + let entry = self.entries.get_mut(&page_id).unwrap(); + // I48 INVARIANT: id still present and dirty (see flush docstring). + // Lift the plaintext out so write_sealed can take &mut self.io. + let plaintext = *entry.buf; + entry.dirty = false; + self.write_sealed(page_id, &plaintext)?; + } +``` +Note: the borrow shape changes — copy the plaintext out (`*entry.buf`, an 8 KB stack copy, same cost the COW paths already pay) and clear `dirty` before the `write_sealed(&mut self, ...)` call, because `write_sealed` needs `&mut self.io` while `entry` borrows `self.entries`. On the error `?` the page stays clean-but-unwritten, which the I1 poison model already covers (see the flush DURABILITY WINDOW docstring — no change to that contract). + +```rust +// load_page disk branch (src/page_cache.rs:892-902): read the on-disk UNIT, +// open it if encrypted, then verify_checksum on the plaintext as today. + let plaintext: [u8; PAGE_SIZE] = match &self.cipher { + Some(c) => { + let blob = self.io.read_page_unit(page_id)?; + // blob is exactly ENC_PAGE_SIZE (stride); open verifies the AEAD + // tag (anti-tamper) and returns the 8192-byte plaintext. + let unit: [u8; ENC_PAGE_SIZE] = blob + .as_slice() + .try_into() + .map_err(|_| ChiselError::DecryptionFailed { page_id })?; + c.open(page_id, &unit) + .map_err(|_| ChiselError::DecryptionFailed { page_id })? + } + None => self.io.read_page(page_id)?, + }; + if !page::verify_checksum(&plaintext) { + return Err(ChiselError::ChecksumMismatch { page_id }); + } + self.entries.insert( + page_id, + CacheEntry { + buf: Box::new(plaintext), + dirty: false, + }, + ); + self.lru.push_front(page_id); + Ok(()) +``` +Add `use crate::crypto::ENC_PAGE_SIZE;` to the module imports (near `src/page_cache.rs:46`). + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test encrypted_page_round_trips_through_seal_open tampered_ciphertext_surfaces_decryption_failed` Expected: PASS +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(page_cache): seal on flush, open+verify on cold load for encrypted DBs" +``` + +--- + +### Task 3.4: Seal-once on evict-to-spillway; verbatim copy on drain + +**Files:** +- Modify: `src/page_cache.rs:866` (load_page spillway-resident branch), `src/page_cache.rs:986` (maybe_evict Phase B spill), `src/page_cache.rs:444` (flush Phase 1b drain), `src/page_cache.rs:1064` (ensure_spillway — pass payload_size) +- Test: `src/page_cache.rs` (`#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: Task 3.2 `Spillway::{open_file,open_memory}(.., payload_size)`, `spill(id, &[u8])`, `rehydrate(id) -> Vec`; Task 3.3 `cipher`, `PageCipher::{seal,open}` +- Produces: encrypted spill/drain. The spillway stores the SEALED blob; drain copies it to the main file with NO re-seal. + +Seal-once invariant on the spill path: evict seals the plaintext ONCE into the spillway as ciphertext. Drain reads that ciphertext and `write_page_unit`s it to the main file verbatim — no second seal (a second seal would generate a fresh nonce and re-encrypt needlessly, and would force the cache to hold plaintext through drain anyway). Re-loading a spilled page from the spillway (`load_page`) must `open` the ciphertext back to plaintext before it re-enters `entries`. + +- [ ] **Step 1: Write the failing test** +```rust + #[test] + fn encrypted_spill_and_drain_round_trips() { + // Cache of 2 pages with a 4-page spillway; allocate 4 dirty pages so 2 + // spill. Stamp each, flush (which drains), then cold-read all 4 back. + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("test.chisel"); + let mut io = PageIo::open(&db_path, false).unwrap(); + io.set_stride(ENC_PAGE_SIZE); + let max_pages = 2usize; + let slot = (crate::spillway::SLOT_HEADER_SIZE + ENC_PAGE_SIZE) as u64; + let mut cache = PageCache::new( + io, + max_pages as u64 * PAGE_SIZE as u64, + slot * 8, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + cache.set_cipher(PageCipher::new(random_dek())); + + let mut ids = Vec::new(); + for n in 0..4u8 { + let pid = cache.new_page().unwrap(); + let buf = cache.get_mut(pid).unwrap(); + buf[0] = 0x40 | n; + crate::page::stamp_checksum(buf); + ids.push(pid); + } + // At least one page must have spilled (4 dirty > 2-page cache). + assert!(cache.spillway.as_ref().unwrap().slot_count() > 0); + + cache.flush().unwrap(); // drains spillway into the main file verbatim + for (n, &pid) in ids.iter().enumerate() { + cache.test_drop_from_cache(pid); + let read = cache.get(pid).unwrap(); + assert_eq!(read[0], 0x40 | n as u8, "page {pid} content survived spill+drain"); + } + } +``` +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test encrypted_spill_and_drain_round_trips` Expected: FAIL (spill takes a slice now but ensure_spillway opens with the old single-arg signature / PAGE_SIZE payload; drain re-routes through write_page; cipher not applied on spill) +- [ ] **Step 3: Implement** + +```rust +// ensure_spillway (src/page_cache.rs:1084): open with the correct payload_size. +// payload = ENC_PAGE_SIZE when a cipher is installed, else PAGE_SIZE. + if self.spillway.is_none() { + let payload_size = if self.cipher.is_some() { + ENC_PAGE_SIZE + } else { + PAGE_SIZE + }; + let spw = match &self.spillway_location { + crate::SpillwayLocation::Path(p) => { + crate::spillway::Spillway::open_file(p, self.spillway_max_bytes, payload_size)? + } + crate::SpillwayLocation::InMemory => { + crate::spillway::Spillway::open_memory(self.spillway_max_bytes, payload_size) + } + }; + self.spillway = Some(spw); + } +``` + +```rust +// maybe_evict Phase B (src/page_cache.rs:1045-1047): seal the plaintext ONCE +// before spilling, so the spillway carries ciphertext. `entry.buf` is plaintext. +// Build the spill payload outside the and_then so the borrow of &entry.buf +// doesn't tangle with ensure_spillway's &mut self. + let payload: Vec = match &self.cipher { + Some(c) => c.seal(victim_id, &entry.buf).to_vec(), + None => entry.buf.to_vec(), + }; + let spill_result = self + .ensure_spillway() + .and_then(|spw| spw.spill(victim_id, &payload)); + if let Err(e) = spill_result { + if entry.dirty { + self.dirty_count += 1; + } + self.lru.push_back(victim_id); + self.entries.insert(victim_id, entry); + return Err(e); + } +``` + +```rust +// flush Phase 1b drain (src/page_cache.rs:473-499): rehydrate returns the SEALED +// blob; write it to the main file VERBATIM (no re-seal). The cache re-insert +// must hold PLAINTEXT, so open the blob for the in-memory entry. For a plaintext +// DB the blob IS the page image and open is a no-op copy. + for page_id in batch { + let blob = { + let spw = self.spillway.as_mut().unwrap(); + let b = spw.rehydrate(page_id)?; // sealed ciphertext (encrypted DB) + spw.forget(page_id); + b + }; + // Verbatim copy of the sealed unit to the main file: seal-once. + self.io.write_page_unit(page_id, &blob)?; + // Re-insert as clean PLAINTEXT so cache reads return page images. + let plaintext: [u8; PAGE_SIZE] = match &self.cipher { + Some(c) => { + let unit: [u8; ENC_PAGE_SIZE] = blob + .as_slice() + .try_into() + .map_err(|_| ChiselError::DecryptionFailed { page_id })?; + c.open(page_id, &unit) + .map_err(|_| ChiselError::DecryptionFailed { page_id })? + } + None => { + let mut p = [0u8; PAGE_SIZE]; + p.copy_from_slice(&blob); + p + } + }; + let entry = CacheEntry { + buf: Box::new(plaintext), + dirty: false, + }; + if let std::collections::hash_map::Entry::Vacant(e) = self.entries.entry(page_id) { + e.insert(entry); + match drain_policy { + crate::DrainInsertion::LruTail => self.lru.push_back(page_id), + crate::DrainInsertion::Mru => self.lru.push_front(page_id), + } + } + } +``` + +```rust +// load_page spillway-resident branch (src/page_cache.rs:872-887): rehydrate +// returns sealed ciphertext; open it before the plaintext entry enters the cache. + if let Some(spw) = self.spillway.as_mut() { + if spw.is_resident(page_id) { + let blob = spw.rehydrate(page_id)?; + spw.forget(page_id); + let plaintext: [u8; PAGE_SIZE] = match &self.cipher { + Some(c) => { + let unit: [u8; ENC_PAGE_SIZE] = blob + .as_slice() + .try_into() + .map_err(|_| ChiselError::DecryptionFailed { page_id })?; + c.open(page_id, &unit) + .map_err(|_| ChiselError::DecryptionFailed { page_id })? + } + None => { + let mut p = [0u8; PAGE_SIZE]; + p.copy_from_slice(&blob); + p + } + }; + self.entries.insert( + page_id, + CacheEntry { + buf: Box::new(plaintext), + dirty: true, // re-loaded spilled page is dirty + }, + ); + self.dirty_count += 1; + self.lru.push_front(page_id); + return Ok(()); + } + } +``` +Note: a re-loaded spilled page is marked `dirty: true` and the spillway slot is `forget`-ten. On the encrypted path this means the plaintext will be re-sealed (fresh nonce) at the next flush/evict — which is correct: it left the spillway, so the "seal-once" unit is gone and the page is live plaintext again. Seal-once means "not sealed twice while ciphertext is in flight to disk," not "the same nonce forever." + +Existing plaintext `fresh_cache_with_spillway` spillway tests still pass: no cipher, `payload_size == PAGE_SIZE`, `spill`/`rehydrate` carry the page image, drain copies it verbatim — byte-identical to the old `write_page` path. + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test encrypted_spill_and_drain_round_trips ; cargo test` Expected: PASS (full suite — confirms the plaintext spill/drain regression set still passes under the seal-once routing). +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(page_cache): seal-once on spill, verbatim sealed-blob copy on drain" +``` + +--- + +Phase 3 deliverable: an encrypted read/write/flush/spill/drain path. `page_io` moves `stride`-byte on-disk units crypto-agnostically; `PageCache` holds the `PageCipher`, seals plaintext exactly once (flush Phase 1a and evict-to-spillway), opens+verifies on cold load and on spillway re-load, and copies sealed blobs verbatim on drain. The spillway carries the sealed blob with its own XXH3 slot checksum. + +Cross-phase dependencies (flagged, not invented): +- `ChiselError::DecryptionFailed { page_id }` (fatal) is defined in **Task 4.1, which is implemented first** (see the plan header's execution-order exception), so it is in scope here. The page-read path maps `PageCipher::open` failures to it explicitly via `.map_err(|_| ChiselError::DecryptionFailed { page_id })`. +- `crate::crypto::{PageCipher, ENC_PAGE_SIZE, random_dek}` and `page::stamp_checksum` are Phase-1 / existing `page.rs` symbols consumed verbatim. +- Engine wiring (call `io.set_stride(ENC_PAGE_SIZE)` + `cache.set_cipher(...)` at open after the DEK is unwrapped) is Phase 2's `TransactionManager`/`Chisel::open` responsibility; Phase 3 only provides the `set_stride`/`set_cipher` entry points. + +Relevant files: `/Users/xof/Documents/Dev/chisel/src/page_io.rs`, `/Users/xof/Documents/Dev/chisel/src/spillway.rs`, `/Users/xof/Documents/Dev/chisel/src/page_cache.rs`. + +--- + +## Phase 4: Public API, error variants, and Python bindings + +### Task 4.1: Add encryption error variants to `ChiselError` + +**Files:** +- Modify: `src/error.rs:158` (after the `UnsupportedPageSize` variant, before the closing `}` of the enum at line 162) +- Modify: `src/error.rs:181` (`is_fatal()` matches! list) +- Modify: `src/error.rs:278` (`Display` impl, after the `UnsupportedPageSize` arm) +- Modify: `src/error.rs:495` (test `documented_is_fatal` Fatal block) and `:540`/`:551` (the `all` array + the `== 9` tripwire) +- Test: `#[cfg(test)] mod tests` in `src/error.rs` (extend existing) + new `encryption_error_classification` test + +**Interfaces:** +- Consumes: nothing from earlier phases (pure type additions). **Execution order: implement this task FIRST — before Phase 2** — because Phases 2, 3, and 5 return these variants (see the plan header's execution-order exception). +- Produces (later phases map these at the engine + Python layers): + - `ChiselError::NoEncryptionKey` (operational) + - `ChiselError::InvalidEncryptionKey` (operational) + - `ChiselError::EncryptionNotSupported` (operational) + - `ChiselError::NoFreeKeySlot` (operational; key-management — all 8 slots occupied) + - `ChiselError::LastKeySlot` (operational; key-management — refusing to remove the only active credential) + - `ChiselError::DecryptionFailed { page_id: u64 }` (fatal; `is_fatal() == true`) + +- [ ] **Step 1: Write the failing test** + +Add to `src/error.rs`'s `mod tests`: +```rust + // Phase 4: the three operational encryption errors are recoverable (the + // on-disk DB is intact — the caller supplied the wrong/no key, or asked an + // old binary to read a v2 file), so is_fatal() is false. DecryptionFailed + // is fatal: an AEAD tag failure on a page read means the ciphertext or DEK + // is wrong and the snapshot can't be trusted, so it must poison (I1). + #[test] + fn encryption_error_classification() { + assert!(!ChiselError::NoEncryptionKey.is_fatal()); + assert!(!ChiselError::InvalidEncryptionKey.is_fatal()); + assert!(!ChiselError::EncryptionNotSupported.is_fatal()); + assert!(!ChiselError::NoFreeKeySlot.is_fatal()); + assert!(!ChiselError::LastKeySlot.is_fatal()); + assert!(ChiselError::DecryptionFailed { page_id: 7 }.is_fatal()); + + // Display carries the page id for the fatal variant. + let msg = format!("{}", ChiselError::DecryptionFailed { page_id: 7 }); + assert!(msg.contains('7'), "Display {msg:?} should mention page id 7"); + + // source() is None for all four — none wrap an inner cause. + use std::error::Error; + for e in [ + ChiselError::NoEncryptionKey, + ChiselError::InvalidEncryptionKey, + ChiselError::EncryptionNotSupported, + ChiselError::NoFreeKeySlot, + ChiselError::LastKeySlot, + ChiselError::DecryptionFailed { page_id: 0 }, + ] { + assert!(e.source().is_none()); + } + } +``` +Also extend the existing exhaustiveness test `is_fatal_matches_documented_classification_for_every_variant`: add the six new variants to `documented_is_fatal`'s blocks (five operational, one fatal), to the `all` array, and bump the fatal-count tripwire from `9` to `10` (the one new fatal variant is `DecryptionFailed`). + +- [ ] **Step 2: Run test, verify it fails** + +Run: `cargo test encryption_error_classification` Expected: FAIL (variants don't exist — compile error) + +- [ ] **Step 3: Implement** + +In the enum, after the `UnsupportedPageSize { stored, compiled }` variant (src/error.rs:161, inside the Operational/Fatal region — place the three operational ones with the operational block and the fatal one with the fatal block; placement is by comment-block only since the enum is flat): +```rust + // Operational — the caller supplied the wrong key material or none, or + // asked an unencrypted-only build to open an encrypted DB. The on-disk + // file is untouched; the caller fixes their `Options` and retries. + // + // Raised at open time when the superblock declares encryption but + // `Options::encryption_key` was None. + NoEncryptionKey, + // Raised at open time when a key was supplied but no key-slot's wrapped + // DEK could be unwrapped under the derived KEK (wrong passphrase / raw + // key). Operational: the DB is intact; supply the right key and reopen. + InvalidEncryptionKey, + // Raised when a key was supplied to open a *plaintext* DB, or an + // encrypted DB is opened by a build that the on-disk crypto-header + // algorithm id is unknown to. Operational: the request is a mismatch, + // not corruption. + EncryptionNotSupported, + // Operational — key-management (add/rotate/remove) ran out of room: all + // KEY_SLOT_COUNT (8) wrapped-DEK slots are occupied, so there is nowhere + // to stage a new credential. The DB is intact; remove an unused key first. + NoFreeKeySlot, + // Operational — refusing to remove the last active key slot, which would + // leave the database with zero usable credentials (permanently unopenable). + LastKeySlot, + // Fatal — an AEAD authentication failure while decrypting a page that + // was already located and read off disk. The ciphertext, tag, nonce, or + // session DEK disagree, so the last-committed snapshot cannot be trusted; + // poisons the manager (I1) exactly like ChecksumMismatch. Distinct from + // InvalidEncryptionKey (a *key-slot* unwrap failure at open, before any + // page is served) — this fires mid-session on a real data/handle page. + DecryptionFailed { + page_id: u64, + }, +``` + +In `is_fatal()` (src/error.rs:181), add `DecryptionFailed` to the `matches!`: +```rust + | ChiselError::UnsupportedPageSize { .. } + | ChiselError::DecryptionFailed { .. } +``` + +In `Display` (after the `UnsupportedPageSize` arm at src/error.rs:281): +```rust + ChiselError::NoEncryptionKey => write!( + f, + "database is encrypted but no encryption_key was supplied" + ), + ChiselError::InvalidEncryptionKey => write!( + f, + "encryption key does not match any key slot (wrong passphrase or raw key)" + ), + ChiselError::EncryptionNotSupported => write!( + f, + "encryption not supported for this open (key supplied for a plaintext database, or unknown crypto algorithm)" + ), + ChiselError::NoFreeKeySlot => write!( + f, + "no free key slot: all 8 key slots are occupied (remove an unused key first)" + ), + ChiselError::LastKeySlot => write!( + f, + "refusing to remove the last active key slot (the database would become permanently unopenable)" + ), + ChiselError::DecryptionFailed { page_id } => { + write!(f, "decryption/authentication failed for page {page_id}") + } +``` + +In the exhaustiveness test `documented_is_fatal`: add `NoEncryptionKey | InvalidEncryptionKey | EncryptionNotSupported` to the operational (`=> false`) block and `ChiselError::DecryptionFailed { .. }` to the fatal (`=> true`) block; add all four to the `all` array; change `assert_eq!(all.iter().filter(|e| e.is_fatal()).count(), 9)` to `10`. + +- [ ] **Step 4: Run test, verify it passes** + +Run: `cargo test` Expected: PASS (both the new test and the updated exhaustiveness test) + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(error): add encryption error variants (NoEncryptionKey, InvalidEncryptionKey, EncryptionNotSupported, NoFreeKeySlot, LastKeySlot, fatal DecryptionFailed)" +``` + +--- + +### Task 4.2: Re-export `Key` / `Argon2Params` and add the `Options` encryption fields + +**Files:** +- Modify: `src/lib.rs:65` (re-export block — add crypto types) +- Modify: `src/lib.rs:39-55` (module list — add `pub(crate) mod crypto;`) +- Modify: `src/lib.rs:133` (`Options` struct fields, after `superblock_count`) +- Modify: `src/lib.rs:176` (`Default for Options`, add the two `None` defaults) +- Modify: `src/lib.rs:207` (`impl Options` — add two builder setters after `superblock_count`) +- Test: `#[cfg(test)] mod tests` at the bottom of `src/lib.rs` (add if absent) + +**Interfaces:** +- Consumes (from Phase 1, `src/crypto/mod.rs`): `pub enum Key { Raw(..), Passphrase(..) }`, `pub struct Argon2Params { pub m_cost: u32, pub t_cost: u32, pub p_cost: u32 }` (`Clone`). +- Produces: + - `pub use crypto::{Key, Argon2Params};` + - `Options.encryption_key: Option` + - `Options.argon2_params: Option` + - `Options::encryption_key(self, key: Key) -> Self` + - `Options::argon2_params(self, params: Argon2Params) -> Self` + +- [ ] **Step 1: Write the failing test** + +Add to `src/lib.rs`: +```rust +#[cfg(test)] +mod options_encryption_tests { + use super::*; + + // The two encryption fields default to None (a plaintext DB) and round-trip + // through the chained-setter builder, preserving #[non_exhaustive] (callers + // can't struct-literal, so the setters are the only construction path). + #[test] + fn encryption_options_default_none_and_set() { + let o = Options::default(); + assert!(o.encryption_key.is_none()); + assert!(o.argon2_params.is_none()); + + let raw = Key::Raw(zeroize::Zeroizing::new(vec![0u8; 32])); + let o = Options::default() + .encryption_key(raw) + .argon2_params(Argon2Params { + m_cost: 19456, + t_cost: 2, + p_cost: 1, + }); + assert!(matches!(o.encryption_key, Some(Key::Raw(_)))); + let p = o.argon2_params.expect("set above"); + assert_eq!((p.m_cost, p.t_cost, p.p_cost), (19456, 2, 1)); + } +} +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `cargo test encryption_options_default_none_and_set` Expected: FAIL (fields/setters/re-exports don't exist) + +- [ ] **Step 3: Implement** + +Add the module (src/lib.rs, in the `pub(crate) mod` block ~line 39): +```rust +pub(crate) mod crypto; +``` + +Add the re-export (src/lib.rs, after the `pub use error::{...}` at line 65): +```rust +pub use crypto::{Argon2Params, Key}; +``` + +Add the two fields to `Options` (after `superblock_count: u32,` at src/lib.rs:139): +```rust + /// Encryption key for an encrypted database. `None` (default) opens or + /// creates a plaintext DB. On create, `Some(key)` makes a new encrypted + /// DB sealed under a random DEK wrapped by this key. On reopen, the key + /// must unwrap one of the on-disk key slots or `open` returns + /// `InvalidEncryptionKey`. Supplying a key to open a plaintext DB returns + /// `EncryptionNotSupported`; omitting it on an encrypted DB returns + /// `NoEncryptionKey`. + pub encryption_key: Option, + /// Argon2id cost parameters used to derive the KEK from a `Key::Passphrase` + /// on *create*. `None` uses `Argon2Params::default()` (OWASP: 19 MiB / t=2 / + /// p=1). Ignored for `Key::Raw` (HKDF, no cost params) and on reopen (the + /// params are read from the key slot the file was written with). + pub argon2_params: Option, +``` + +Add to `Default for Options` (src/lib.rs:189, after `superblock_count: ...,`): +```rust + encryption_key: None, + argon2_params: None, +``` + +Add the two setters to `impl Options` (after the `superblock_count` setter, src/lib.rs:236): +```rust + /// Set the encryption key. See [`Options::encryption_key`] for the + /// create-vs-reopen semantics. + pub fn encryption_key(mut self, key: Key) -> Self { + self.encryption_key = Some(key); + self + } + /// Set the Argon2id cost parameters used when deriving a KEK from a + /// passphrase on database creation. No effect for raw keys or on reopen. + pub fn argon2_params(mut self, params: Argon2Params) -> Self { + self.argon2_params = Some(params); + self + } +``` + +Add `zeroize` as a dev/normal dep if not already present (Phase 1 adds it to `[dependencies]`; the test above uses `zeroize::Zeroizing` through the public `Key`). No Cargo change needed here if Phase 1 landed it. + +- [ ] **Step 4: Run test, verify it passes** + +Run: `cargo test encryption_options_default_none_and_set` Expected: PASS + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(api): re-export Key/Argon2Params and add encryption_key/argon2_params to Options" +``` + +--- + +### Task 4.3: Wire the key through `open()` / `open_in_memory_with_options()` + +**Files:** +- Modify: `src/lib.rs:341-347` (`open`: the create/open dispatch) and `:332-339` (pass key into the create/open call) +- Modify: `src/lib.rs:381+` (`open_in_memory_with_options`: same dispatch) +- Test: `tests/encryption_roundtrip.rs` (new integration test — runs under plain `cargo test`) + +**Interfaces:** +- Consumes (from Phase 2): the create/open flow now accepts the key. The exact Phase-2 signatures are: + - `TransactionManager::create_new(cache, superblock_count, encryption_key: Option) -> Result` + - `TransactionManager::open_existing(cache, encryption_key: Option) -> Result` + These extend the current 2-arg / 1-arg signatures at `src/lib.rs:344` and `:346`. Phase 2 is responsible for adding the `encryption_key` parameter; this task passes `options.encryption_key` into them. +- Produces: end-to-end encrypted open behavior the public API guarantees. + +- [ ] **Step 1: Write the failing test** + +Create `tests/encryption_roundtrip.rs`: +```rust +// End-to-end public-API encryption contract (Phase 4): create encrypted → +// write → reopen with the same key reads it back; wrong key → +// InvalidEncryptionKey; no key → NoEncryptionKey. Uses a raw 32-byte key so +// the test does not pay the Argon2id cost (passphrase derivation is covered in +// the crypto unit tests). +use chisel::{ChiselError, Chisel, Key, Options}; +use zeroize::Zeroizing; + +fn raw_key(b: u8) -> Key { + Key::Raw(Zeroizing::new(vec![b; 32])) +} + +#[test] +fn encrypted_roundtrip_and_wrong_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("enc.db"); + + // Create encrypted, write a value, capture the handle, close. + let handle = { + let mut db = Chisel::open( + &path, + Options::default().encryption_key(raw_key(0xAB)), + ) + .expect("create encrypted"); + db.begin().expect("begin"); + let h = db.allocate(b"secret-payload").expect("allocate"); + db.commit().expect("commit"); + h.get() + }; + + // Reopen with the SAME key: value reads back. + { + let db = Chisel::open( + &path, + Options::default() + .create_if_missing(false) + .encryption_key(raw_key(0xAB)), + ) + .expect("reopen with correct key"); + let v = db.read(chisel::Handle::from(handle)).expect("read"); + assert_eq!(&v, b"secret-payload"); + } + + // Reopen with the WRONG key: InvalidEncryptionKey. + { + let err = Chisel::open( + &path, + Options::default() + .create_if_missing(false) + .encryption_key(raw_key(0x00)), + ) + .expect_err("wrong key must fail"); + assert!( + matches!(err, ChiselError::InvalidEncryptionKey), + "expected InvalidEncryptionKey, got {err:?}" + ); + } + + // Reopen with NO key: NoEncryptionKey. + { + let err = Chisel::open( + &path, + Options::default().create_if_missing(false), + ) + .expect_err("missing key must fail"); + assert!( + matches!(err, ChiselError::NoEncryptionKey), + "expected NoEncryptionKey, got {err:?}" + ); + } +} +``` +Ensure `tempfile` is a `[dev-dependencies]` entry (it is already used by the existing integration tests; if not, add `tempfile = "3"`). + +- [ ] **Step 2: Run test, verify it fails** + +Run: `cargo test --test encryption_roundtrip` Expected: FAIL (open ignores `encryption_key`; create/open signatures don't take it yet — compile error until Phase 2 lands, then a behavior failure) + +- [ ] **Step 3: Implement** + +In `Chisel::open` (src/lib.rs:341), pass the key into both arms: +```rust + let txm = if file_exists { + // Existing database: N is discovered from the on-disk superblock. + // options.superblock_count is ignored here. The key (if any) must + // unwrap a key slot; mismatch surfaces as InvalidEncryptionKey, + // a missing key as NoEncryptionKey, both from open_existing. + TransactionManager::open_existing(cache, options.encryption_key)? + } else { + TransactionManager::create_new( + cache, + options.superblock_count, + options.encryption_key, + )? + }; +``` + +In `open_in_memory_with_options` (src/lib.rs, the `create_new` call after line 400): +```rust + let txm = TransactionManager::create_new( + cache, + options.superblock_count, + options.encryption_key, + )?; +``` + +Update the `# Errors` rustdoc on `open` (src/lib.rs:303) to add the new operational errors: +```rust + /// `InvalidSuperblockCount` (the `superblock_count` option is out of + /// range), `FileNotFound` (no file at `path` and `create_if_missing` is + /// false), or `LockFailed` (another handle holds the exclusive flock). + /// For an encrypted database: `NoEncryptionKey` (file is encrypted but + /// no `encryption_key` given), `InvalidEncryptionKey` (key unwraps no key + /// slot), or `EncryptionNotSupported` (key given for a plaintext file). + /// When reopening an existing file, parsing the superblock can also yield + /// `UnsupportedFormatVersion`, `CorruptSuperblock`, `ChecksumMismatch`, + /// `FileSizeMismatch`, or `IoError`. +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `cargo test --test encryption_roundtrip` Expected: PASS + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(api): wire encryption_key through open() and open_in_memory_with_options()" +``` + +--- + +### Task 4.4: Add `encryption_key` kwarg to the Python `open()` + +**Files:** +- Modify: `python/src/db.rs:140-149` (`#[pyo3(signature = (...))]` — add `encryption_key = None`) +- Modify: `python/src/db.rs:157-166` (`open` fn params — add `encryption_key: Option>`) +- Modify: `python/src/db.rs:174-186` (coerce the kwarg → `chisel::Key` under the GIL, before `py.detach`) +- Modify: `python/src/db.rs:208-214` (`Options` builder chain — add `.encryption_key(...)` when present) +- Test: `python/tests/test_encryption.py` (new; mirrors existing python/tests style) + +**Interfaces:** +- Consumes (Phase 4 Task 4.2 / 4.3): `chisel::Key`, `Options::encryption_key`. +- Produces: `chisel.open(path, *, encryption_key=...)` where `bytes` → `Key::Raw`, `str` → `Key::Passphrase`. + +- [ ] **Step 1: Write the failing test** + +Create `python/tests/test_encryption.py`: +```python +import pathlib + +import chisel +import pytest + + +def test_encrypted_roundtrip_with_bytes_key(tmp_path: pathlib.Path): + path = tmp_path / "enc.db" + key = b"\xab" * 32 + + with chisel.open(path, encryption_key=key) as db: + db.begin() + h = db.allocate(b"secret-payload") + db.commit() + + with chisel.open(path, create_if_missing=False, encryption_key=key) as db: + assert db.read(h) == b"secret-payload" + + +def test_wrong_key_raises_invalid_encryption_key(tmp_path: pathlib.Path): + path = tmp_path / "enc.db" + with chisel.open(path, encryption_key=b"\xab" * 32) as db: + db.begin() + db.allocate(b"x") + db.commit() + + with pytest.raises(chisel.InvalidEncryptionKeyError): + chisel.open(path, create_if_missing=False, encryption_key=b"\x00" * 32) + + +def test_missing_key_raises_no_encryption_key(tmp_path: pathlib.Path): + path = tmp_path / "enc.db" + with chisel.open(path, encryption_key=b"\xab" * 32) as db: + db.begin() + db.allocate(b"x") + db.commit() + + with pytest.raises(chisel.NoEncryptionKeyError): + chisel.open(path, create_if_missing=False) + + +def test_passphrase_key_roundtrip(tmp_path: pathlib.Path): + path = tmp_path / "pass.db" + with chisel.open(path, encryption_key="correct horse battery staple") as db: + db.begin() + h = db.allocate(b"v") + db.commit() + with chisel.open( + path, create_if_missing=False, encryption_key="correct horse battery staple" + ) as db: + assert db.read(h) == b"v" +``` + +This depends on the three new Python exception classes (`NoEncryptionKeyError`, `InvalidEncryptionKeyError`, `EncryptionNotSupportedError`) and a `DecryptionFailedError` — added in Task 4.5. Run order: 4.5 lands the exception classes, 4.4 the kwarg; commit 4.4 after 4.5 or fold the exception additions in first. (They are independent files; do 4.5's `errors.rs` edits before running this test.) + +- [ ] **Step 2: Run test, verify it fails** + +Run: `cd python && maturin develop && python -m pytest tests/test_encryption.py` Expected: FAIL (`open()` has no `encryption_key` kwarg) + +- [ ] **Step 3: Implement** + +Add to the signature (python/src/db.rs:140, after `superblock_count = 2`): +```rust + superblock_count = 2, + encryption_key = None +``` + +Add the param (python/src/db.rs:165, after `superblock_count: u32,`): +```rust + superblock_count: u32, + encryption_key: Option>, +``` + +Coerce the kwarg under the GIL, immediately after the `path_buf` extraction block (python/src/db.rs:186, before the spillway resolution). `bytes` → `Key::Raw`, `str` → `Key::Passphrase`; anything else is a `TypeError`: +```rust + // Coerce encryption_key under the GIL (before py.detach): `bytes` → + // Key::Raw, `str` → Key::Passphrase. Done here so a bad type raises a + // synchronous Python TypeError, matching the path coercion above. + let key: Option = match encryption_key { + None => None, + Some(obj) => { + let bound = obj.bind(py); + if let Ok(b) = bound.cast::() { + Some(chisel::Key::Raw(zeroize::Zeroizing::new(b.as_bytes().to_vec()))) + } else if let Ok(s) = bound.cast::() { + Some(chisel::Key::Passphrase(zeroize::Zeroizing::new( + s.to_str()?.to_owned(), + ))) + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "encryption_key must be bytes (raw 32-byte key) or str (passphrase)", + )); + } + } + }; +``` + +Add `.encryption_key(...)` to the builder chain (python/src/db.rs:208). Because `Options::encryption_key` takes `Key` (not `Option`), only call it when present: +```rust + let mut options = chisel::Options::default() + .cache_max_bytes(cache_max_bytes) + .spillway_max_bytes(resolved_spillway_max_bytes) + .drain_insertion(drain_insertion.into()) + .create_if_missing(create_if_missing) + .read_only(read_only) + .superblock_count(superblock_count); + if let Some(k) = key { + options = options.encryption_key(k); + } +``` + +Add `zeroize` to `python/Cargo.toml` `[dependencies]` (`zeroize = "1"`) if not already present — needed for `Zeroizing` here. + +- [ ] **Step 4: Run test, verify it passes** + +Run: `cd python && maturin develop && python -m pytest tests/test_encryption.py` Expected: PASS + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(python): add encryption_key kwarg to open() (bytes->raw, str->passphrase)" +``` + +--- + +### Task 4.5: Map the new errors to Python exception classes + +**Files:** +- Modify: `python/src/errors.rs:88-144` (declare the new exception classes) +- Modify: `python/src/errors.rs:150-235` (`register` — attach them to the module) +- Modify: `python/src/errors.rs:256-359` (`to_py_err` — add concrete arms) +- Modify: `python/src/errors.rs:20-50` (class-hierarchy doc comment) +- Modify: `python/chisel/__init__.py` and `python/chisel/_chisel.pyi` (re-export / declare the four classes — mirror the existing entries) +- Test: covered by `python/tests/test_encryption.py` (Task 4.4) asserting `chisel.NoEncryptionKeyError` / `chisel.InvalidEncryptionKeyError` are raised + +**Interfaces:** +- Consumes: `ChiselError::{NoEncryptionKey, InvalidEncryptionKey, EncryptionNotSupported, DecryptionFailed}` (Task 4.1). +- Produces: Python `NoEncryptionKeyError`, `InvalidEncryptionKeyError`, `EncryptionNotSupportedError` (under `OperationalError`); `DecryptionFailedError` (under `FatalError`). + +- [ ] **Step 1: Write the failing test** + +The two assertions in `python/tests/test_encryption.py` (Task 4.4) already require `chisel.InvalidEncryptionKeyError` and `chisel.NoEncryptionKeyError` to exist and be raised. Add one direct-classification check to that file: +```python +def test_encryption_exception_hierarchy(): + # Operational tier: recoverable, DB intact. + assert issubclass(chisel.NoEncryptionKeyError, chisel.OperationalError) + assert issubclass(chisel.InvalidEncryptionKeyError, chisel.OperationalError) + assert issubclass(chisel.EncryptionNotSupportedError, chisel.OperationalError) + # Fatal tier: drop-and-reopen. + assert issubclass(chisel.DecryptionFailedError, chisel.FatalError) +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `cd python && maturin develop && python -m pytest tests/test_encryption.py::test_encryption_exception_hierarchy` Expected: FAIL (`AttributeError`: classes don't exist) + +- [ ] **Step 3: Implement** + +Declare the classes (python/src/errors.rs, after `create_exception!(_chisel, TagMismatchError, OperationalError);` at line 116 for the operational ones, and after `create_exception!(_chisel, PoisonedError, FatalError);` at line 144 for the fatal one): +```rust +// Encryption key errors — operational: the DB file is intact, the caller +// supplied the wrong/no key or a key for a plaintext DB. Mirrors the three +// operational ChiselError encryption variants. +create_exception!(_chisel, NoEncryptionKeyError, OperationalError); +create_exception!(_chisel, InvalidEncryptionKeyError, OperationalError); +create_exception!(_chisel, EncryptionNotSupportedError, OperationalError); +``` +```rust +// Fatal: an AEAD authentication failure decrypting a page already read off +// disk. Poison-and-reopen, like ChecksumMismatchError. +create_exception!(_chisel, DecryptionFailedError, FatalError); +``` + +Register them (python/src/errors.rs `register`, alongside the other operational adds near line 201, and the fatal add near line 232): +```rust + m.add("NoEncryptionKeyError", py.get_type::())?; + m.add( + "InvalidEncryptionKeyError", + py.get_type::(), + )?; + m.add( + "EncryptionNotSupportedError", + py.get_type::(), + )?; +``` +```rust + m.add("DecryptionFailedError", py.get_type::())?; +``` + +Add concrete arms in `to_py_err` (python/src/errors.rs:280, after the `TagMismatch` operational arm, and after the `Poisoned` fatal arm at line 342): +```rust + RustChiselError::NoEncryptionKey => NoEncryptionKeyError::new_err(msg), + RustChiselError::InvalidEncryptionKey => InvalidEncryptionKeyError::new_err(msg), + RustChiselError::EncryptionNotSupported => EncryptionNotSupportedError::new_err(msg), +``` +```rust + RustChiselError::DecryptionFailed { .. } => DecryptionFailedError::new_err(msg), +``` + +Extend the hierarchy doc comment (python/src/errors.rs:34-50): add the three operational classes under `OperationalError` and `DecryptionFailedError` under `FatalError`. + +In `python/chisel/__init__.py` and `python/chisel/_chisel.pyi`, add the four class names to the re-export list and stub declarations, mirroring the format of the existing `InvalidHandleError` / `PoisonedError` entries. (Grep them for `InvalidHandleError` to find every list that needs the additions.) + +- [ ] **Step 4: Run test, verify it passes** + +Run: `cd python && maturin develop && python -m pytest tests/test_encryption.py` Expected: PASS (all of Task 4.4's tests now pass too) + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(python): map encryption errors to NoEncryptionKeyError/InvalidEncryptionKeyError/EncryptionNotSupportedError/DecryptionFailedError" +``` + +--- + +Notes for the orchestrator: +- **Task ordering:** 4.1 → 4.2 → (4.3 needs Phase 2's `create_new`/`open_existing` key params) → 4.5 before 4.4 (the Python test in 4.4 references the exception classes 4.5 defines), or land both then run. 4.1 is independent of Phases 2/3 and can land first. +- **`zeroize` dep:** Phase 1 adds it to the root `Cargo.toml`. Task 4.2's test and Task 4.4 use `Zeroizing` directly; if Phase 1 hasn't landed when these run, add `zeroize = "1"` to root `[dependencies]` and `python/Cargo.toml` `[dependencies]`. +- I avoided re-listing fatal `DecryptionFailedError` text; the `IoError` two-base machinery is untouched (DecryptionFailed is single-base `FatalError`). + +--- + +## Phase 5: Key management: add / rotate / remove credentials + +All three operations are O(1): they mutate the in-superblock key-slot table and write one new superblock via the ordinary A/B slot-rotation commit. No page is re-encrypted — the per-DB DEK never changes, only its KEK-wrapping in the slot table does. The session DEK (already unwrapped at open, Phase 2/3) is the pivot: every operation either proves possession of it (via `existing`/`old`/`key` unlocking some slot) or re-wraps it under a new KEK. + +Each task assumes the Phase-2 facts: +- `TransactionManager` holds the session `Dek` (field `dek: Option`, `None` for plaintext DBs) and the open superblock's `crypto: Option`. +- A metadata-only superblock write helper exists from Phase 2's commit path. Phase 5 adds `rewrite_crypto_header` as the single building block the three public methods route through; it bumps `txn_counter`, serializes the superblock with the mutated `CryptoHeader`, writes the inactive slot, and fsyncs — identical durability to a data commit, no data pages touched. + +Phase-4 errors consumed verbatim: `ChiselError::InvalidEncryptionKey` (no slot unlocks with the supplied credential), `ChiselError::NoFreeKeySlot` (all 8 slots occupied), `ChiselError::LastKeySlot` (refusing to clear the only active slot), `ChiselError::EncryptionNotSupported` (key op on a plaintext DB). + +--- + +### Task 5.1: Slot-table helpers on `CryptoHeader` (find / unlock / free-slot / wrap-into) + +**Files:** +- Modify: `src/superblock.rs` (add an `impl CryptoHeader` block after the `CryptoHeader` struct introduced in Phase 2; the struct sits alongside `KeySlot`, `KEY_SLOT_COUNT`, `KEY_SLOT_SIZE`) +- Test: `src/superblock.rs` (`#[cfg(test)] mod tests`, same file) + +**Interfaces:** +- Consumes (Phase 1): `crypto::{Key, Kek, Dek, KdfId, Argon2Params, derive_kek, wrap_dek, unwrap_dek, random_array, CryptoError, SALT_LEN, NONCE_LEN, DEK_LEN, TAG_LEN}` +- Consumes (Phase 2): `KeySlot { state: u8, kdf_id: u8, argon2: Argon2Params, salt: [u8; SALT_LEN], wrap_nonce: [u8; NONCE_LEN], wrapped_dek: [u8; DEK_LEN], wrap_tag: [u8; TAG_LEN] }`, `CryptoHeader { algorithm: u8, stride: u32, slots: [KeySlot; KEY_SLOT_COUNT] }`, and the slot-state constants `KEY_SLOT_EMPTY = 0u8` / `KEY_SLOT_ACTIVE = 1u8` (Phase 2 defines these in `superblock.rs`). +- Produces (this task; later tasks rely on): + - `KeySlot::is_active(&self) -> bool` + - `KeySlot::aad(&self) -> [u8; 1 + 1 + 12 + SALT_LEN + NONCE_LEN]` — the slot-metadata bytes used as wrap AAD (binds the wrapped DEK to this slot's KDF identity so a slot can't be transplanted). + - `CryptoHeader::unlock(&self, key: &Key) -> Result<(usize, crypto::Dek), ChiselError>` — returns `(slot_index, dek)` of the first active slot the key opens; `Err(InvalidEncryptionKey)` if none. + - `CryptoHeader::free_slot(&self) -> Option` + - `CryptoHeader::active_count(&self) -> usize` + - `CryptoHeader::wrap_into(&mut self, slot: usize, key: &Key, dek: &crypto::Dek)` — fills `slots[slot]` with a fresh salt + (HKDF default) KDF params, wrapping `dek` under the KEK derived from `key`. + +- [ ] **Step 1: Write the failing test** +```rust +#[cfg(test)] +mod crypto_header_tests { + use super::*; + use crate::crypto::{self, Key, KdfId}; + use zeroize::Zeroizing; + + fn raw(b: u8) -> Key { + Key::Raw(Zeroizing::new(vec![b; 32])) + } + + // A header with exactly one active slot holding `dek` under `key`. + fn header_with_one(key: &Key, dek: &crypto::Dek) -> CryptoHeader { + let mut h = CryptoHeader { + algorithm: 1, + stride: crypto::ENC_PAGE_SIZE as u32, + slots: [KeySlot::EMPTY; KEY_SLOT_COUNT], + }; + h.wrap_into(0, key, dek); + h + } + + #[test] + fn unlock_finds_the_right_slot_and_recovers_dek() { + let dek = crypto::random_dek(); + let k0 = raw(0xA1); + let mut h = header_with_one(&k0, &dek); + + // Add a second credential into slot 3 wrapping the SAME dek. + let k1 = raw(0xB2); + h.wrap_into(3, &k1, &dek); + + let (idx0, d0) = h.unlock(&k0).expect("k0 unlocks"); + let (idx1, d1) = h.unlock(&k1).expect("k1 unlocks"); + assert_eq!(idx0, 0); + assert_eq!(idx1, 3); + // Both recover the identical DEK bytes. + assert_eq!(d0.expose(), dek.expose()); + assert_eq!(d1.expose(), dek.expose()); + } + + #[test] + fn unlock_wrong_key_is_wrongkey_not_panic() { + let dek = crypto::random_dek(); + let h = header_with_one(&raw(0x01), &dek); + let err = h.unlock(&raw(0x99)).unwrap_err(); + assert_eq!(err, ChiselError::InvalidEncryptionKey); + } + + #[test] + fn free_slot_and_active_count_track_occupancy() { + let dek = crypto::random_dek(); + let mut h = header_with_one(&raw(0x01), &dek); + assert_eq!(h.active_count(), 1); + assert_eq!(h.free_slot(), Some(1)); + + // Fill every slot. + for i in 1..KEY_SLOT_COUNT { + h.wrap_into(i, &raw(i as u8 + 1), &dek); + } + assert_eq!(h.active_count(), KEY_SLOT_COUNT); + assert_eq!(h.free_slot(), None); + } +} +``` +(`KeySlot::EMPTY`, `crypto::Dek::expose()`, and `crypto::ENC_PAGE_SIZE` are Phase-1/Phase-2 surface; `Dek::expose(&self) -> &[u8; DEK_LEN]` is the test-only accessor Phase 1 defines under `#[cfg(test)]`.) + +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test crypto_header_tests` Expected: FAIL (methods not yet implemented) + +- [ ] **Step 3: Implement** +```rust +// In src/superblock.rs, after the CryptoHeader struct (Phase 2). +use crate::crypto::{self, Argon2Params, KdfId, Key, SALT_LEN, NONCE_LEN}; + +impl KeySlot { + pub const EMPTY: KeySlot = KeySlot { + state: KEY_SLOT_EMPTY, + kdf_id: 0, + argon2: Argon2Params { m_cost: 0, t_cost: 0, p_cost: 0 }, + salt: [0u8; SALT_LEN], + wrap_nonce: [0u8; NONCE_LEN], + wrapped_dek: [0u8; crypto::DEK_LEN], + wrap_tag: [0u8; crypto::TAG_LEN], + }; + + pub fn is_active(&self) -> bool { + self.state == KEY_SLOT_ACTIVE + } + + // AAD binds the wrapped DEK to this slot's KDF identity: kdf_id, the + // three Argon2 cost words, the salt, and the wrap nonce. Re-deriving the + // KEK requires the exact salt/params, so transplanting a wrapped DEK into + // a slot with different metadata fails the Poly1305 tag — a slot cannot be + // forged from another slot's ciphertext. + pub fn aad(&self) -> [u8; 2 + 12 + SALT_LEN + NONCE_LEN] { + let mut a = [0u8; 2 + 12 + SALT_LEN + NONCE_LEN]; + a[0] = self.state; + a[1] = self.kdf_id; + a[2..6].copy_from_slice(&self.argon2.m_cost.to_le_bytes()); + a[6..10].copy_from_slice(&self.argon2.t_cost.to_le_bytes()); + a[10..14].copy_from_slice(&self.argon2.p_cost.to_le_bytes()); + a[14..14 + SALT_LEN].copy_from_slice(&self.salt); + a[14 + SALT_LEN..].copy_from_slice(&self.wrap_nonce); + a + } +} + +impl CryptoHeader { + pub fn active_count(&self) -> usize { + self.slots.iter().filter(|s| s.is_active()).count() + } + + pub fn free_slot(&self) -> Option { + self.slots.iter().position(|s| !s.is_active()) + } + + /// Find the first active slot `key` unlocks; recover the DEK from it. + /// `InvalidEncryptionKey` if no active slot's KEK validates the wrapped DEK tag. + pub fn unlock(&self, key: &Key) -> Result<(usize, crypto::Dek), ChiselError> { + for (i, slot) in self.slots.iter().enumerate() { + if !slot.is_active() { + continue; + } + // kdf_id is the on-disk u8; map to the typed KdfId. An unknown id + // means a slot written by a newer format — skip it (treated like a + // non-matching credential) rather than erroring the whole open. + let kdf = match slot.kdf_id { + x if x == KdfId::Hkdf as u8 => KdfId::Hkdf, + x if x == KdfId::Argon2id as u8 => KdfId::Argon2id, + _ => continue, + }; + let kek = match crypto::derive_kek(key, kdf, &slot.salt, &slot.argon2) { + Ok(k) => k, + Err(_) => continue, + }; + if let Ok(dek) = crypto::unwrap_dek( + &kek, + &slot.wrapped_dek, + &slot.wrap_tag, + &slot.wrap_nonce, + &slot.aad(), + ) { + return Ok((i, dek)); + } + } + Err(ChiselError::InvalidEncryptionKey) + } + + /// Populate `slots[slot]` with a fresh HKDF salt + nonce, wrapping `dek` + /// under the KEK derived from `key`. Always uses HKDF for raw keys and + /// Argon2id for passphrases — the caller never picks the KDF; it follows + /// the key variant (matches the open-time derivation in Phase 1/2). + pub fn wrap_into(&mut self, slot: usize, key: &Key, dek: &crypto::Dek) { + let (kdf_id, argon2) = match key { + Key::Raw(_) => (KdfId::Hkdf, Argon2Params { m_cost: 0, t_cost: 0, p_cost: 0 }), + Key::Passphrase(_) => (KdfId::Argon2id, Argon2Params::default()), + }; + let salt: [u8; SALT_LEN] = crypto::random_array(); + let wrap_nonce: [u8; NONCE_LEN] = crypto::random_array(); + + let mut s = KeySlot { + state: KEY_SLOT_ACTIVE, + kdf_id: kdf_id as u8, + argon2, + salt, + wrap_nonce, + wrapped_dek: [0u8; crypto::DEK_LEN], + wrap_tag: [0u8; crypto::TAG_LEN], + }; + // AAD is computed over the slot metadata as it will sit on disk; the + // wrapped_dek/wrap_tag fields are zero at AAD time (aad() does not read + // them), so the AAD is stable before and after the wrap. + let kek = crypto::derive_kek(key, kdf_id, &s.salt, &s.argon2) + .expect("KEK derivation for a freshly-generated salt cannot fail"); + let (wrapped, tag) = crypto::wrap_dek(&kek, dek, &s.wrap_nonce, &s.aad()); + s.wrapped_dek = wrapped; + s.wrap_tag = tag; + self.slots[slot] = s; + } +} +``` + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test crypto_header_tests` Expected: PASS + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(crypto): key-slot table helpers (unlock/free_slot/wrap_into) on CryptoHeader" +``` + +--- + +### Task 5.2: `TransactionManager::rewrite_crypto_header` — the metadata-only superblock commit + +**Files:** +- Create: `src/transaction/keys.rs` +- Modify: `src/transaction/mod.rs:239` (add `mod keys;` next to the other `mod` declarations ending at line 239) +- Test: `src/transaction/keys.rs` (`#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `TransactionManager` fields `cache: RefCell`, `committed_roots: Roots`, `txn_counter: u64`, `superblock_count: u32`, `poisoned: Cell`, and the Phase-2 fields `crypto: Option`, `dek: Option`. Consumes `Superblock::serialize` (it serializes `self.encryption: Option` in Phase 2) and `PageCache::io_mut().{write_page, fsync}`. Consumes `TransactionManager::poison_on_fatal` (existing). +- Produces: `pub(crate) fn rewrite_crypto_header(&mut self, new_header: CryptoHeader) -> Result<()>` — refuses if `active_txn`, bumps `txn_counter`, writes the inactive slot, fsyncs, then commits `new_header` into `self.crypto`. + +- [ ] **Step 1: Write the failing test** +```rust +#[cfg(test)] +mod tests { + use crate::{Chisel, Options}; + use crate::crypto::Key; + use zeroize::Zeroizing; + use tempfile::TempDir; + + fn raw(b: u8) -> Key { Key::Raw(Zeroizing::new(vec![b; 32])) } + + // Rewriting the header out-of-band (here: re-wrapping the same DEK under a + // second slot) must survive a reopen, because it goes through the same + // fsync'd A/B superblock write the data commit uses. + #[test] + fn rewritten_header_persists_across_reopen() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let opts = Options::default().encryption_key(raw(0x11)); // Phase 2 setter + + { + let mut db = Chisel::open(&path, opts.clone()).unwrap(); + db.add_key(&raw(0x11), &raw(0x22)).unwrap(); // exercises rewrite + db.close().unwrap(); + } + // Reopen with the second key only: only durable if the rewritten + // superblock reached disk. + let db = Chisel::open(&path, Options::default().encryption_key(raw(0x22))).unwrap(); + assert!(!db.is_poisoned()); + } +} +``` + +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test rewritten_header_persists_across_reopen` Expected: FAIL (`add_key`/`encryption_key` not yet present) + +- [ ] **Step 3: Implement** +```rust +//! transaction::keys — out-of-band key-slot management. Each operation rewrites +//! ONLY the in-superblock CryptoHeader and commits it via the ordinary A/B +//! superblock slot rotation — no data page is touched and the per-DB DEK never +//! changes, so there is no re-encryption. Durability is identical to a data +//! commit: bump txn_counter, write the inactive slot, fsync (the linearization +//! point), then promote the in-memory header. + +use super::*; +use crate::superblock::CryptoHeader; + +impl TransactionManager { + /// Write a superblock carrying `new_header`, leaving every other root + /// untouched. Refuses while a transaction is active (a key op is a + /// standalone metadata commit, not composable with in-flight data work). + /// + /// On any I/O/fsync failure the handle is poisoned: a half-written key-slot + /// table is exactly the fsyncgate hazard the poison model exists for. The + /// previous superblock slot still holds the last-good header, so a crash + /// here is recoverable on reopen — the in-memory promotion happens only + /// after the fsync returns. + pub(crate) fn rewrite_crypto_header(&mut self, new_header: CryptoHeader) -> Result<()> { + if self.poisoned.get() { + return Err(ChiselError::Poisoned); + } + if self.active_txn { + return Err(ChiselError::TransactionInProgress); + } + self.rewrite_crypto_header_inner(new_header) + .inspect_err(|e| self.poison_on_fatal(e)) + } + + fn rewrite_crypto_header_inner(&mut self, new_header: CryptoHeader) -> Result<()> { + let mut cache = self.cache.borrow_mut(); + // No dirty data pages exist between transactions, but flush keeps the + // invariant "everything the new superblock could reference is durable" + // honest even if a future change leaves the cache dirty here. + cache.flush()?; + + self.txn_counter = self + .txn_counter + .checked_add(1) + .expect("txn_counter overflowed u64 (2^64 commits) — unreachable"); + let total_pages = cache.file_page_count()?; + let r = &self.committed_roots; + let sb = Superblock { + magic: page::MAGIC, + format_version: page::FORMAT_VERSION, + txn_counter: self.txn_counter, + root_handle_table_page: r.handle_table_page, + root_freemap_page: r.freemap_page, + total_pages, + next_handle: r.next_handle, + page_size: PAGE_SIZE as u32, + named_roots: r.named_roots, + superblock_count: self.superblock_count, + root_membership_index_page: r.membership_index_page, + freemap_depth: r.freemap_depth, + // Phase 2: Superblock::serialize seals the sensitive body under the + // DEK and writes the plaintext crypto header from this field. + encryption: Some(new_header.clone()), + }; + let buf = sb.serialize_encrypted(self.dek.as_ref()); + let inactive = self.txn_counter % self.superblock_count as u64; + cache.io_mut().write_page(inactive, &buf)?; + cache.io_mut().fsync()?; + + // Linearized: promote the new header in memory only now. + self.crypto = Some(new_header); + self.committed_roots.total_pages = total_pages; + Ok(()) + } +} +``` +(`Superblock` gains `encryption: Option` and `serialize_encrypted(&self, dek: Option<&crypto::Dek>) -> [u8; PAGE_SIZE]` in Phase 2; `total_pages` is re-read rather than assumed because a prior data commit may have grown the file.) + +- [ ] **Step 4: Run test, verify it passes** (passes once Tasks 5.3 wires `add_key`) +Run: `cargo test rewritten_header_persists_across_reopen` Expected: PASS + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(crypto): rewrite_crypto_header metadata-only superblock commit" +``` + +--- + +### Task 5.3: `Chisel::add_key` and `Chisel::rotate_key` + +**Files:** +- Modify: `src/transaction/keys.rs` (add `add_key` / `rotate_key` on `TransactionManager`) +- Modify: `src/lib.rs:893` (add the two public methods inside `impl Chisel`, before the closing brace at line 893) +- Test: `tests/encryption_keys.rs` (integration; the public flow needs `Chisel::open`) + +**Interfaces:** +- Consumes: `CryptoHeader::{unlock, free_slot, wrap_into}` (Task 5.1), `TransactionManager::rewrite_crypto_header` (Task 5.2), `self.crypto: Option`. +- Produces: + - `TransactionManager::add_key(&mut self, existing: &Key, new: &Key) -> Result<()>` + - `TransactionManager::rotate_key(&mut self, old: &Key, new: &Key) -> Result<()>` + - `Chisel::add_key(&mut self, existing: &Key, new: &Key) -> Result<()>` + - `Chisel::rotate_key(&mut self, old: &Key, new: &Key) -> Result<()>` + +- [ ] **Step 1: Write the failing test** +```rust +// tests/encryption_keys.rs +use chisel::{Chisel, Options}; +use chisel::crypto::Key; +use zeroize::Zeroizing; +use tempfile::TempDir; + +fn raw(b: u8) -> Key { Key::Raw(Zeroizing::new(vec![b; 32])) } + +#[test] +fn add_key_lets_either_credential_open() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + + let h = { + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + db.begin().unwrap(); + let h = db.allocate(b"secret").unwrap(); + db.commit().unwrap(); + db.add_key(&raw(1), &raw(2)).unwrap(); + db.close().unwrap(); + h + }; + + // Original key still works. + let db1 = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + assert_eq!(db1.read(h).unwrap(), b"secret"); + db1.close().unwrap(); + // New key also works. + let db2 = Chisel::open(&path, Options::default().encryption_key(raw(2))).unwrap(); + assert_eq!(db2.read(h).unwrap(), b"secret"); +} + +#[test] +fn add_key_wrong_existing_is_wrongkey() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + let err = db.add_key(&raw(9), &raw(2)).unwrap_err(); + assert_eq!(err, chisel::ChiselError::InvalidEncryptionKey); +} + +#[test] +fn add_key_full_table_is_nofreeslot() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + // Slot 0 occupied at open; add 7 more to fill all 8. + for k in 2u8..=8 { + db.add_key(&raw(1), &raw(k)).unwrap(); + } + let err = db.add_key(&raw(1), &raw(99)).unwrap_err(); + assert_eq!(err, chisel::ChiselError::NoFreeKeySlot); +} + +#[test] +fn rotate_key_revokes_old_and_admits_new() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + { + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + db.rotate_key(&raw(1), &raw(2)).unwrap(); + db.close().unwrap(); + } + // Old key is now refused. + assert_eq!( + Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap_err(), + chisel::ChiselError::InvalidEncryptionKey + ); + // New key opens. + let db = Chisel::open(&path, Options::default().encryption_key(raw(2))).unwrap(); + assert!(!db.is_poisoned()); +} +``` +(`Chisel::open` mapping a failed all-slot unlock to `ChiselError::InvalidEncryptionKey` is Phase 2/4.) + +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test --test encryption_keys` Expected: FAIL (`add_key`/`rotate_key` not present) + +- [ ] **Step 3: Implement** +```rust +// src/transaction/keys.rs — append to the impl block. +use crate::crypto::Key; + +impl TransactionManager { + /// Prove possession of `existing` (it must unlock some active slot), + /// recover the DEK, then wrap that SAME DEK under `new` in a free slot and + /// commit the new header. The DEK is unchanged, so existing pages stay + /// readable under both credentials. + pub(crate) fn add_key(&mut self, existing: &Key, new: &Key) -> Result<()> { + if self.poisoned.get() { + return Err(ChiselError::Poisoned); + } + let header = self.crypto.as_ref().ok_or(ChiselError::EncryptionNotSupported)?; + let (_idx, dek) = header.unlock(existing)?; // InvalidEncryptionKey if none + let free = header.free_slot().ok_or(ChiselError::NoFreeKeySlot)?; + let mut new_header = header.clone(); + new_header.wrap_into(free, new, &dek); + self.rewrite_crypto_header(new_header) + } + + /// add_key(old, new) then clear the slot `old` occupied, in a single new + /// superblock. After commit, `old` no longer unlocks any slot and `new` + /// does. If `old == new` would collapse to the same credential, the cleared + /// slot is the OLD one (found by re-unlocking with `old` after the add), so + /// the freshly-added `new` slot survives. + pub(crate) fn rotate_key(&mut self, old: &Key, new: &Key) -> Result<()> { + if self.poisoned.get() { + return Err(ChiselError::Poisoned); + } + let header = self.crypto.as_ref().ok_or(ChiselError::EncryptionNotSupported)?; + let (old_idx, dek) = header.unlock(old)?; + let free = header.free_slot().ok_or(ChiselError::NoFreeKeySlot)?; + let mut new_header = header.clone(); + new_header.wrap_into(free, new, &dek); + // Revoke the old slot in the SAME header rewrite — one fsync, atomic: + // a crash leaves either the pre-rotation header (old works) or the + // post-rotation header (new works), never a window where neither does. + new_header.slots[old_idx] = crate::superblock::KeySlot::EMPTY; + self.rewrite_crypto_header(new_header) + } +} +``` +```rust +// src/lib.rs — inside impl Chisel, before line 893's closing brace. +use crypto::Key; + + /// Add a second credential that unlocks this database. `existing` must + /// already unlock it; `new` is wrapped over the same data key into a free + /// key slot. After this returns, either credential opens the database. This + /// is an O(1) superblock commit — no page is re-encrypted. + /// + /// # Errors + /// `EncryptionNotSupported` if the database has no encryption; `InvalidEncryptionKey` if + /// `existing` unlocks no slot; `NoFreeKeySlot` if all 8 key slots are full. + /// A failure inside the fsync/superblock write is fatal and poisons the handle. + pub fn add_key(&mut self, existing: &Key, new: &Key) -> Result<()> { + self.txm.add_key(existing, new) + } + + /// Replace `old` with `new`: `new` is added and `old` is revoked in one + /// atomic superblock commit. After this returns, `old` no longer opens the + /// database and `new` does. O(1) — the data key is unchanged, so no page is + /// re-encrypted. + /// + /// # Errors + /// `EncryptionNotSupported` if the database has no encryption; `InvalidEncryptionKey` if `old` + /// unlocks no slot; `NoFreeKeySlot` if all 8 key slots are full (no room to + /// stage `new` before revoking `old`). A failure inside the fsync/superblock + /// write is fatal and poisons the handle. + pub fn rotate_key(&mut self, old: &Key, new: &Key) -> Result<()> { + self.txm.rotate_key(old, new) + } +``` + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test --test encryption_keys` Expected: PASS + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(crypto): Chisel::add_key and rotate_key" +``` + +--- + +### Task 5.4: `Chisel::remove_key` (refuse the last active slot) + +**Files:** +- Modify: `src/transaction/keys.rs` (add `remove_key` on `TransactionManager`) +- Modify: `src/lib.rs:893` (add the public method inside `impl Chisel`) +- Test: `tests/encryption_keys.rs` (extend) + +**Interfaces:** +- Consumes: `CryptoHeader::{unlock, active_count}` (Task 5.1), `KeySlot::EMPTY` (Task 5.1), `rewrite_crypto_header` (Task 5.2). +- Produces: `TransactionManager::remove_key(&mut self, key: &Key) -> Result<()>`, `Chisel::remove_key(&mut self, key: &Key) -> Result<()>`. + +- [ ] **Step 1: Write the failing test** +```rust +// tests/encryption_keys.rs — append. +#[test] +fn remove_key_leaves_others_working() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let h = { + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + db.begin().unwrap(); + let h = db.allocate(b"v").unwrap(); + db.commit().unwrap(); + db.add_key(&raw(1), &raw(2)).unwrap(); + db.remove_key(&raw(1)).unwrap(); // drop the first credential + db.close().unwrap(); + h + }; + // raw(1) is gone. + assert_eq!( + Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap_err(), + chisel::ChiselError::InvalidEncryptionKey + ); + // raw(2) still opens and reads. + let db = Chisel::open(&path, Options::default().encryption_key(raw(2))).unwrap(); + assert_eq!(db.read(h).unwrap(), b"v"); +} + +#[test] +fn remove_last_key_is_rejected() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + // Only one active slot — removing it would brick the database. + let err = db.remove_key(&raw(1)).unwrap_err(); + assert_eq!(err, chisel::ChiselError::LastKeySlot); + // Still openable afterward — the rejected op changed nothing. + drop(db); + let db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + assert!(!db.is_poisoned()); +} + +#[test] +fn remove_unknown_key_is_wrongkey() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + db.add_key(&raw(1), &raw(2)).unwrap(); + assert_eq!(db.remove_key(&raw(9)).unwrap_err(), chisel::ChiselError::InvalidEncryptionKey); +} +``` + +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test --test encryption_keys` Expected: FAIL (`remove_key` not present) + +- [ ] **Step 3: Implement** +```rust +// src/transaction/keys.rs — append to the impl block. +impl TransactionManager { + /// Clear the slot `key` unlocks. Refuses to remove the LAST active slot + /// (`LastKeySlot`) — a database with zero usable credentials is + /// unrecoverable, so this is an operational error that changes nothing. + pub(crate) fn remove_key(&mut self, key: &Key) -> Result<()> { + if self.poisoned.get() { + return Err(ChiselError::Poisoned); + } + let header = self.crypto.as_ref().ok_or(ChiselError::EncryptionNotSupported)?; + let (idx, _dek) = header.unlock(key)?; // InvalidEncryptionKey if none + // Check occupancy AFTER proving the key is valid: an unknown key on a + // single-slot DB should report InvalidEncryptionKey, not LastKeySlot. + if header.active_count() <= 1 { + return Err(ChiselError::LastKeySlot); + } + let mut new_header = header.clone(); + new_header.slots[idx] = crate::superblock::KeySlot::EMPTY; + self.rewrite_crypto_header(new_header) + } +} +``` +```rust +// src/lib.rs — inside impl Chisel. + /// Revoke the credential `key`. After this returns, `key` no longer opens + /// the database; any other credentials are unaffected. Refuses to remove + /// the only remaining credential. O(1) superblock commit. + /// + /// # Errors + /// `EncryptionNotSupported` if the database has no encryption; `InvalidEncryptionKey` if `key` + /// unlocks no slot; `LastKeySlot` if `key` is the only active credential + /// (removing it would make the database permanently unopenable — nothing is + /// changed). A failure inside the fsync/superblock write is fatal and + /// poisons the handle. + pub fn remove_key(&mut self, key: &Key) -> Result<()> { + self.txm.remove_key(key) + } +``` + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test --test encryption_keys` Expected: PASS + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(crypto): Chisel::remove_key with last-slot guard" +``` + +--- + +### Task 5.5: Mirror `add_key` / `rotate_key` / `remove_key` in the Python binding + +**Files:** +- Modify: `python/src/db.rs:233` (add three methods inside `#[pymethods] impl PyChisel`, the block opening at line 233; `with_db`-style mutable access helpers at lines ~564/573 and `to_py_err` at line 50 already exist) +- Test: `python/tests/test_encryption_keys.py` + +**Interfaces:** +- Consumes: `Chisel::{add_key, rotate_key, remove_key}` (Tasks 5.3/5.4); the existing `to_py_err` (`python/src/db.rs:50`) and the `Mutex>` accessor pattern (`python/src/db.rs:87`). Python passes keys as `bytes` (32-byte raw) or `str` (passphrase); map to `crypto::Key` exactly as the Phase-2 `open()` kwarg does. +- Produces: `PyChisel.add_key(self, existing, new)`, `PyChisel.rotate_key(self, old, new)`, `PyChisel.remove_key(self, key)`. + +- [ ] **Step 1: Write the failing test** +```python +# python/tests/test_encryption_keys.py +import tempfile, os, pytest, chisel + +K1 = bytes([1]) * 32 +K2 = bytes([2]) * 32 + +def _open(path, key): + return chisel.open(path, encryption_key=key) + +def test_add_key_either_opens(): + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, "db") + db = _open(p, K1) + db.begin(); h = db.allocate(b"secret"); db.commit() + db.add_key(K1, K2) + db.close() + db1 = _open(p, K1); assert db1.read(h) == b"secret"; db1.close() + db2 = _open(p, K2); assert db2.read(h) == b"secret"; db2.close() + +def test_rotate_key_revokes_old(): + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, "db") + db = _open(p, K1); db.rotate_key(K1, K2); db.close() + with pytest.raises(Exception): + _open(p, K1) + _open(p, K2).close() + +def test_remove_last_key_rejected(): + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, "db") + db = _open(p, K1) + with pytest.raises(Exception): + db.remove_key(K1) + db.close() +``` + +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test -p chisel-python 2>/dev/null; (cd python && maturin develop && python -m pytest tests/test_encryption_keys.py)` Expected: FAIL (methods not bound) + +- [ ] **Step 3: Implement** +```rust +// python/src/db.rs — inside #[pymethods] impl PyChisel. + +// Map a Python key argument (bytes -> raw 32-byte, str -> passphrase) to a +// crypto::Key. Mirrors the open() kwarg coercion so the binding has one key +// vocabulary. A raw key of the wrong length surfaces as the engine's +// BadKeyLength via to_py_err once it reaches derive/unlock. +fn py_key(obj: &Bound<'_, PyAny>) -> PyResult { + use zeroize::Zeroizing; + if let Ok(b) = obj.extract::>() { + Ok(chisel::crypto::Key::Raw(Zeroizing::new(b))) + } else if let Ok(s) = obj.extract::() { + Ok(chisel::crypto::Key::Passphrase(Zeroizing::new(s))) + } else { + Err(pyo3::exceptions::PyTypeError::new_err( + "key must be bytes (raw) or str (passphrase)", + )) + } +} + + pub(crate) fn add_key( + &self, + existing: &Bound<'_, PyAny>, + new: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let existing = py_key(existing)?; + let new = py_key(new)?; + self.with_db_mut(|db| db.add_key(&existing, &new)) + } + + pub(crate) fn rotate_key( + &self, + old: &Bound<'_, PyAny>, + new: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let old = py_key(old)?; + let new = py_key(new)?; + self.with_db_mut(|db| db.rotate_key(&old, &new)) + } + + pub(crate) fn remove_key(&self, key: &Bound<'_, PyAny>) -> PyResult<()> { + let key = py_key(key)?; + self.with_db_mut(|db| db.remove_key(&key)) + } +``` +(`with_db_mut` is the existing locked-mutable accessor that takes `FnOnce(&mut Chisel) -> Result<_>` and applies `to_py_err`; it is the helper at `python/src/db.rs:564`/`573`. If that helper is `&self`/`&mut self`-shaped differently in the real file, match its exact signature — the three methods above only need "lock, take `&mut Chisel`, map_err to_py_err".) + +- [ ] **Step 4: Run test, verify it passes** +Run: `(cd python && maturin develop && python -m pytest tests/test_encryption_keys.py)` Expected: PASS + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(python): bind add_key/rotate_key/remove_key on PyChisel" +``` + +--- + +Notes on what was deliberately skipped, and why it's safe: +- No re-encryption / DEK rotation. `rotate_key` rotates the *credential*, not the data key — the contract's envelope design (random per-DB DEK wrapped per slot) makes credential rotation O(1); rotating the DEK itself would mean rewriting every page and is not in this phase's scope. Add a `reencrypt()` later if a leaked-DEK threat model demands it. +- `rotate_key` requires a free slot (it stages `new` before revoking `old`). On a full 8-slot table it returns `NoFreeKeySlot` rather than clearing `old` first — staging-then-revoke keeps the operation atomic (no crash window with zero working keys). Acceptable: 8 slots is already generous; the caller can `remove_key` then `add_key` if they truly want in-place reuse. +- The `aad()` binding (Task 5.1) is the one non-obvious security-load-bearing bit: it ties each wrapped DEK to its slot's KDF metadata so a ciphertext can't be lifted between slots. That check is exercised by `unlock_finds_the_right_slot_and_recovers_dek` (transplant would fail the tag). + +Phase 5 file footprint: one new file (`src/transaction/keys.rs`), edits to `src/superblock.rs`, `src/transaction/mod.rs`, `src/lib.rs`, `python/src/db.rs`, plus two test files. + +--- + +## Phase 6: Docs, ADR, format-version bump, and deferred-work record + +This phase carries no new engine logic. The MAJOR-version gate at `src/transaction/recovery.rs:171` already exists and already rejects a file whose MAJOR differs from `FORMAT_MAJOR_VERSION` (currently 1). The encryption design (spec §8) calls for encrypted DBs to stamp **MAJOR=2** while plaintext DBs keep **MAJOR=1**. Because the global `FORMAT_MAJOR_VERSION` constant must stay `1` (plaintext DBs are the default and must not be rejected by their own binary), the bump is *per-database at create time*, not a constant change: the create path stamps `pack_format_version(2, 0)` into the superblock when encryption is enabled, and the existing gate does the rest. This phase adds a small helper + the documentation, then verifies the gate behaves. + +> Note: the create path that stamps the encrypted superblock's `format_version` is built in Phase 2 (superblock crypto-header). This phase only adds the version *constant/helper* that Phase 2 consumes, plus a gate-behavior test, plus docs. If Phase 2 already added `ENCRYPTED_FORMAT_MAJOR_VERSION`, Task 6.1 collapses to verifying it; the test in Task 6.1 still applies. + +--- + +### Task 6.1: Add the encrypted-DB MAJOR-version constant and prove the gate rejects it on an old binary + +**Files:** +- Modify: `src/page.rs:113` (add a sibling constant next to `FORMAT_MAJOR_VERSION`) +- Test: `src/transaction/recovery.rs` (`#[cfg(test)] mod` — exercises the gate at line 171 with a MAJOR=2 superblock) + +**Interfaces:** +- Consumes: `page::pack_format_version(major, minor) -> u32` (`src/page.rs:117`), `page::format_major(version) -> u16` (`src/page.rs:122`), `page::FORMAT_MAJOR_VERSION: u16 = 1` (`src/page.rs:113`). +- Produces: `pub const ENCRYPTED_FORMAT_MAJOR_VERSION: u16 = 2;` and `pub const ENCRYPTED_FORMAT_VERSION: u32` — consumed by the Phase 2 create path when `Options.encryption_key.is_some()`. + +- [ ] **Step 1: Write the failing test** + +Add to the existing test module in `src/transaction/recovery.rs` (the gate under test is the `format_major` check at line 171). The test stamps an encrypted-DB MAJOR (2) into a freshly serialized superblock, writes it as page 0, and asserts open fails with `UnsupportedFormatVersion`. This proves an *encryption-unaware* binary (whose `FORMAT_MAJOR_VERSION` is 1) hard-rejects a MAJOR=2 file, exactly as spec §7/§8 require. + +```rust +#[test] +fn encrypted_major_version_is_rejected_by_plaintext_binary() { + use crate::page::{self, PAGE_SIZE}; + use crate::superblock::Superblock; + use crate::error::ChiselError; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("enc_major.chsl"); + + // Build a valid superblock, then overwrite its format_version with the + // encrypted-DB MAJOR (2, minor 0). Everything else is a normal empty DB. + let mut sb = Superblock::new_empty(); + sb.format_version = page::ENCRYPTED_FORMAT_VERSION; // pack(2, 0) + assert_eq!(page::format_major(sb.format_version), 2); + + // Lay it down as page 0 at plaintext stride (the gate fires before any + // stride-dependent read; page 0 is always at offset 0). + let bytes = sb.serialize(); + assert_eq!(bytes.len(), PAGE_SIZE); + std::fs::write(&path, &bytes).unwrap(); + + // An encryption-unaware open (FORMAT_MAJOR_VERSION == 1) must reject it. + let err = crate::Chisel::open(&path, crate::Options::default()).unwrap_err(); + match err { + ChiselError::UnsupportedFormatVersion { found, expected } => { + assert_eq!(page::format_major(found), 2); + assert_eq!(expected, page::FORMAT_VERSION); // pack(1, 1) + } + other => panic!("expected UnsupportedFormatVersion, got {other:?}"), + } +} +``` + +(Use whatever `Superblock` constructor the existing recovery tests already use — `Superblock::new_empty()` is named here per `src/superblock.rs:562`-area test constructors; if the in-tree name differs, match the sibling tests in this module rather than inventing one. The gate assertion is the load-bearing part.) + +- [ ] **Step 2: Run test, verify it fails** +Run: `cargo test encrypted_major_version_is_rejected_by_plaintext_binary` Expected: FAIL — `page::ENCRYPTED_FORMAT_VERSION` does not exist yet (does not compile). + +- [ ] **Step 3: Implement** + +In `src/page.rs`, immediately after the `FORMAT_MINOR_VERSION` line (currently `src/page.rs:114`), add the encrypted-DB version constants. Do **not** change `FORMAT_MAJOR_VERSION` — plaintext DBs (the default) must keep MAJOR=1 or their own binary would reject them. + +```rust +// Encrypted databases stamp a DISTINCT MAJOR so an encryption-unaware binary +// hard-rejects them at the open-time gate (recovery.rs) instead of misreading +// 8232-byte sealed strides as 8192-byte plaintext pages. Plaintext DBs are +// unaffected: they keep FORMAT_MAJOR_VERSION (1). The create path picks which +// version to stamp based on Options.encryption_key — this is a per-database +// choice, NOT a global constant change. See the on-disk-encryption design §8 +// and ISSUES.md. +// +// MINOR is 0 here (first encrypted release); the same packed-MAJOR gate that +// guards plaintext also guards this: a MAJOR-2 file opened by a MAJOR-1 binary +// yields UnsupportedFormatVersion. +pub const ENCRYPTED_FORMAT_MAJOR_VERSION: u16 = 2; +pub const ENCRYPTED_FORMAT_MINOR_VERSION: u16 = 0; +pub const ENCRYPTED_FORMAT_VERSION: u32 = + pack_format_version(ENCRYPTED_FORMAT_MAJOR_VERSION, ENCRYPTED_FORMAT_MINOR_VERSION); +``` + +No gate change is needed: `recovery.rs:171` already does `if page::format_major(sb.format_version) != page::FORMAT_MAJOR_VERSION`. An encryption-unaware binary has `FORMAT_MAJOR_VERSION == 1`, so a MAJOR-2 file fails the comparison and returns `UnsupportedFormatVersion`. (When the engine later learns encryption, that gate grows a second accepted MAJOR — out of scope for this docs phase; the constant defined here is what Phase 2's create path stamps.) + +- [ ] **Step 4: Run test, verify it passes** +Run: `cargo test encrypted_major_version_is_rejected_by_plaintext_binary` Expected: PASS + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(format): add encrypted-DB MAJOR version constant and gate-rejection test" +``` + +--- + +### Task 6.2: Add the "On-disk encryption" section to ARCHITECTURE.md + +**Files:** +- Modify: `ARCHITECTURE.md` — insert a new `### On-disk encryption` section immediately after `### Format versioning (two-tier)` (the section ends at `ARCHITECTURE.md:616`; the `---` separator + `## Benchmark infrastructure` follow at 618/620). Insert between line 616 and the `---` at 618. +- Modify: `ARCHITECTURE.md:7` table of contents (add an entry under "Cross-cutting concepts"). +- Test: link/build check only — `cargo build` stays green (docs-only change touches no code) and a grep confirms the new heading and TOC entry exist. + +**Interfaces:** +- Consumes: the approved spec `docs/specs/2026-06-29-on-disk-encryption-design.md` (§3 envelope, §4 stride, §8 versioning, §9 threat model). +- Produces: durable architecture documentation; no code symbols. + +- [ ] **Step 1: Write the failing check** + +There is no Rust test for prose. The "failing test" is a grep that must find the new heading and TOC entry; before the edit it returns nothing. + +```bash +grep -n "### On-disk encryption" ARCHITECTURE.md +grep -n "On-disk encryption" ARCHITECTURE.md # expect a TOC line too +``` +Expected before the edit: no match (exit 1). + +- [ ] **Step 2: Run check, verify it fails** +Run: `grep -c "### On-disk encryption" ARCHITECTURE.md` Expected: `0` (section absent). + +- [ ] **Step 3: Implement** + +First add the TOC entry. The TOC lists "Cross-cutting concepts" subsections around `ARCHITECTURE.md:7`; add a line in that subsection list (keep the existing indentation/style of sibling TOC lines): + +```markdown + - [On-disk encryption](#on-disk-encryption) +``` + +Then insert this section between `ARCHITECTURE.md:616` (end of the two-tier versioning prose) and the `---` at line 618. Keep it consistent with the spec; **no Claude/AI references.** + +```markdown +### On-disk encryption + +Encryption is **opt-in per database**, chosen at create time by supplying an +`Options.encryption_key`. Without a key a database is plaintext exactly as before; +the encrypted format is a strict superset that lives **below** the page +abstraction, so the page cache, freemap, data pages, handle table, and the entire +transaction layer keep producing and consuming byte-identical 8192-byte page +images. + +**Envelope scheme.** A random 256-bit Data Encryption Key (DEK), generated once +with the OS RNG at create time, seals every page and the sensitive superblock +fields and never changes during normal operation. The DEK is wrapped under a Key +Encryption Key (KEK) derived per-open from the client key plus a per-slot salt: +HKDF-SHA256 for raw high-entropy keys, Argon2id (memory-hard) for human +passphrases. Up to eight key-slots in the superblock's plaintext reserved region +each hold one KEK-wrapped copy of the same DEK. A successful unwrap (its Poly1305 +tag verifies) *is* the proof the client key is correct — there is no separate +password verifier. The unwrapped DEK is held zeroizing in `TransactionManager` for +the open session and wiped on drop; the KEK and client key are zeroized +immediately after use. + +**Cipher and page stride.** The AEAD is **XChaCha20-Poly1305** with a fresh +random 192-bit nonce per page write — chosen because it is constant-time in +portable software (no AES-NI dependency) and the 192-bit nonce makes random +nonces collision-safe, which sidesteps a keystream-reuse hazard that shadow-paging +page reuse would create under a deterministic `(page_id, counter)` nonce. Each +encrypted page occupies an **8232-byte** on-disk stride: `ciphertext(8192) ‖ +tag(16) ‖ nonce(24)`, written at offset `page_id × 8232` (vs `× 8192` for +plaintext). `AAD = page_id` gives anti-relocation — a sealed page authenticates +only at its own `page_id`. `page_io` is stride-aware but crypto-agnostic; the +seal/open transform lives one layer up in the page cache, which seals a page +exactly once when it first leaves the plaintext cache (to the main file or the +spillway) and byte-copies the already-sealed blob on spillway drain. + +**Superblock.** Page 0 stays at offset 0 regardless of stride, so it is always +readable first to learn `encrypted? / algorithm / stride / key-slots`. Bootstrap +fields (`magic`, `format_version`, `txn_counter`, `page_size`, +`superblock_count`) and the plaintext crypto-header (flag, algorithm id, stride, +key-slot table) stay in the clear; the sensitive body — root pointers, +`total_pages`, `next_handle`, `freemap_depth`, and the user-chosen `named_roots` +names — is sealed under the DEK as a `nonce ‖ tag ‖ ciphertext` sub-blob whose AAD +binds it to this superblock's identity. The plaintext portion keeps its XXH3 +checksum so the A/B torn-write `select()` still works on bootstrap fields. + +**Format version.** Encrypted databases stamp file-level **MAJOR = 2**; plaintext +databases stay at MAJOR = 1. The existing open-time MAJOR gate (it compares MAJOR +only) therefore hard-rejects an encrypted DB on an encryption-unaware binary with +`UnsupportedFormatVersion`, preventing it from misreading ciphertext as plaintext. +No per-page (I31) format change is needed — the logical page image is unchanged. + +**Key rotation.** Credential rotation is O(1) and crash-safe: `add_key` derives a +KEK from a new credential and wraps the *same* DEK into a free slot; `rotate_key` +is `add_key` then clear the old slot; `remove_key` clears a slot (refusing the +last active one). Each is an ordinary superblock commit through the A/B + fsync +protocol — no page is ever re-encrypted. + +**Threat-model boundary.** Provided: confidentiality of all data and sensitive +metadata at rest, AEAD tamper-detection (surfaced as the fatal `DecryptionFailed`, +which poisons the engine), and anti-relocation. **Not** provided: rollback/replay +resistance — an attacker who substitutes a wholly older, validly-signed database +image (or an older valid A/B superblock slot) cannot be detected by self-contained +authentication, which would require an external monotonic trust anchor (e.g. TPM); +in-memory protection beyond zeroize-on-drop; and traffic-analysis hiding (file +size, page count, and access patterns are visible). Bulk DEK rotation / full +re-encryption is deferred (see ISSUES.md). +``` + +- [ ] **Step 4: Run check, verify it passes** +Run: `grep -c "### On-disk encryption" ARCHITECTURE.md && grep -c "On-disk encryption](#on-disk-encryption)" ARCHITECTURE.md && cargo build` Expected: section grep `1`, TOC grep `1`, build succeeds. + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "docs(architecture): document the on-disk encryption envelope scheme and threat boundary" +``` + +--- + +### Task 6.3: Record deferred bulk DEK rotation in ISSUES.md + +**Files:** +- Modify: `ISSUES.md` — append a new entry. The file uses a `## ` + per-item structure (see the `## Durability and crash safety` heading at `ISSUES.md:60`); the highest existing id seen in MEMORY/notes is **I141** (the deferred StagingTxn extraction). Use the next free id **I142**. Append under a new `## On-disk encryption` category section at the end of the file (after the last existing category), matching the existing entry format. +- Test: grep confirms the entry exists and carries a priority tag; `cargo build` unaffected. + +**Interfaces:** +- Consumes: spec §1 (out of scope), §2.1, §3.3 (rotation scope v1 = credential only). +- Produces: the deferred-work record the spec's Phase 6 requires; no code symbols. + +- [ ] **Step 1: Write the failing check** +```bash +grep -n "I142" ISSUES.md +``` +Expected before the edit: no match. + +- [ ] **Step 2: Run check, verify it fails** +Run: `grep -c "I142" ISSUES.md` Expected: `0`. + +- [ ] **Step 3: Implement** + +Append to the end of `ISSUES.md` (after the final existing category). Match the surrounding entry style — bold id, priority tag, one-line title, then rationale prose. + +```markdown + +--- + +## On-disk encryption + +Source: **[encryption 2026-06-29]** — deferred-work captured while implementing the +on-disk encryption feature (design at `docs/specs/2026-06-29-on-disk-encryption-design.md`). + +**I142** (P3) — **Bulk DEK rotation / full re-encryption is deferred.** v1 supports +only *credential* rotation: `add_key` / `rotate_key` / `remove_key` re-wrap the +single per-DB Data Encryption Key (DEK) under a new Key Encryption Key in an +O(1), crash-safe superblock commit — no page is re-encrypted. Rotating the **DEK +itself** (re-sealing every page and the superblock body under a fresh DEK) is a +heavy whole-file operation reserved for the case where the DEK is believed +compromised, and is intentionally out of scope for v1 (design §1, §3.3). Deferred +because: (a) credential rotation covers the normal operational meaning of "rotate +my key"; (b) a correct online bulk re-encrypt has to be crash-safe and resumable +across the entire file — a feature-sized effort that wants its own spec/plan; and +(c) there are no production databases yet, so no DEK is currently at risk. When +implemented it should reuse the existing seal-once page path and the A/B +superblock protocol, and run as a resumable background sweep (a per-DB +"re-encryption watermark" so an interrupted rotation continues rather than +restarts). No code stub exists today; this entry is the record that the omission +is deliberate, not an oversight. +``` + +- [ ] **Step 4: Run check, verify it passes** +Run: `grep -c "I142" ISSUES.md && grep -c "Bulk DEK rotation / full re-encryption is deferred" ISSUES.md` Expected: both `>= 1`. + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "docs(issues): record deferred bulk DEK rotation (I142)" +``` + +--- + +### Task 6.4: Update the ADR graph via codebase-memory (manual checklist — implementer runs the skill) + +**Files:** +- Modify: `.codebase-memory/adr.md` (untracked; edited only through the `manage_adr` tool, never by hand). +- Test: post-update `manage_adr(mode=get)` shows BOTH the new encryption section AND every previously-existing section intact. + +**Interfaces:** +- Consumes: the approved spec, the ARCHITECTURE.md section from Task 6.2, and the I142 record from Task 6.3. +- Produces: an ADR entry for the on-disk encryption decision in the project's ADR graph. + +This task is **not** code — it is a procedure the implementer runs by hand using the `codebase-memory` skill / `manage_adr` tool. It is listed as a checklist so it is not skipped before the PR is opened (per the project rule: update the ADR graph *before* recommending a PR). + +> **FOOTGUN — `manage_adr(mode=update)` overwrites the WHOLE document.** There is no +> section-scoped write. Passing only a fragment as `content` leaves the ADR holding +> *only* that fragment; every other section is destroyed. The `sections` argument +> does **not** scope the write. Always snapshot-then-read-modify-write. + +- [ ] **Step 1: Snapshot the current ADR (makes the overwrite reversible)** +Run the `codebase-memory` skill's `manage_adr(mode=get)` for the Chisel project. Save the **entire returned document verbatim** to the session scratchpad (e.g. `…/scratchpad/adr-snapshot-pre-encryption.md`) BEFORE editing anything. This snapshot is the only thing that makes Step 3 reversible. + +- [ ] **Step 2: Compose the new section inside the full document** +In a working copy of the full snapshot, add ONE new ADR section for on-disk encryption, leaving every other section byte-for-byte unchanged. The new section records the load-bearing decisions (cite the spec for each): + - **Decision:** opt-in, per-DB authenticated at-rest encryption. + - **AEAD:** XChaCha20-Poly1305, random 192-bit nonce per page write, `AAD = page_id` (spec §2.1 — why random-nonce over deterministic, given shadow-paging page reuse). + - **Key management:** envelope (random per-DB DEK wrapped by a KEK), HKDF-SHA256 for raw keys / Argon2id for passphrases, 8 key-slots, O(1) credential rotation (spec §3). + - **Page format:** 8232-byte on-disk stride, logical page stays 8192; seal-once at the page-cache layer; `page_io` stride-aware but crypto-agnostic (spec §4). + - **Format version:** MAJOR 1→2 for encrypted DBs only; the existing MAJOR gate hard-rejects old binaries (spec §8). + - **Boundaries:** no rollback/replay protection, no in-memory protection beyond zeroize, no size hiding; bulk DEK rotation deferred → ISSUES.md I142 (spec §9). + +- [ ] **Step 3: Write the ENTIRE edited document back** +Call `manage_adr(mode=update, content=)` — the complete text from Step 2, not just the new section. Never pass a section fragment to `mode=update`. + +- [ ] **Step 4: Verify nothing was lost** +Call `manage_adr(mode=get)` again and confirm the new encryption section is present **and** every section that existed in the Step 1 snapshot still exists, unchanged. If any prior section is missing, recover by re-running `manage_adr(mode=update)` with the Step 1 snapshot (rebuilt to include the new section) — never hand-edit the codebase-memory SQLite store (it is shared across all indexed projects). + +- [ ] **Step 5: (no commit)** +The ADR lives in the untracked `.codebase-memory/` store, not in git — there is nothing to commit for this task. With Tasks 6.1–6.3 committed and the ADR updated, the encryption feature is documented and the branch is ready for `gh pr create --base master`. + +--- + +**Phase 6 deliverables:** `src/page.rs` gains `ENCRYPTED_FORMAT_MAJOR_VERSION` / `ENCRYPTED_FORMAT_VERSION` (consumed by Phase 2's create path) with a test proving the existing gate at `src/transaction/recovery.rs:171` rejects a MAJOR=2 file on an encryption-unaware binary; `ARCHITECTURE.md` gains the "On-disk encryption" section + TOC entry; `ISSUES.md` gains the I142 deferred-bulk-DEK-rotation record; and the ADR graph is updated via the snapshot-then-overwrite `manage_adr` procedure. From 6b3c33e0fb8c5bbd4cdd4b0996ceeaf00d019de7 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 19:28:45 -0700 Subject: [PATCH 03/42] build: add RustCrypto deps and register crypto core module --- Cargo.lock | 236 ++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 11 +++ src/crypto/mod.rs | 27 ++++++ src/lib.rs | 1 + 4 files changed, 275 insertions(+) create mode 100644 src/crypto/mod.rs diff --git a/Cargo.lock b/Cargo.lock index eb9fa36..7fe1e18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "ahash" version = "0.8.12" @@ -94,6 +104,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "assert_cmd" version = "2.2.2" @@ -115,6 +137,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -136,6 +164,24 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -175,16 +221,46 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chisel" version = "0.1.0" dependencies = [ + "argon2", + "chacha20poly1305", + "getrandom 0.2.17", + "hkdf", "libc", "pastey", "proptest", "rustc-hash", + "sha2", "tempfile", "xxhash-rust", + "zeroize", ] [[package]] @@ -258,6 +334,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.5.61" @@ -310,6 +397,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "criterion" version = "0.5.1" @@ -377,12 +473,34 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "either" version = "1.16.0" @@ -465,6 +583,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -557,6 +685,24 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "hostname" version = "0.4.2" @@ -610,6 +756,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -747,6 +902,23 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pastey" version = "0.1.1" @@ -793,6 +965,17 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1202,6 +1385,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1226,6 +1420,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -1272,6 +1472,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unarray" version = "0.1.4" @@ -1290,6 +1496,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "utf8parse" version = "0.2.2" @@ -1631,6 +1847,26 @@ dependencies = [ "syn", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 86c04ce..1560ee2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,17 @@ libc = "0.2" # (this is only the third runtime dependency). The crates.io package is # `rustc-hash` (hyphen); the import path is `rustc_hash` (underscore). rustc-hash = "2" +# On-disk encryption (spec 2026-06-29). All pure-Rust, well-vetted RustCrypto +# primitives — rolling our own crypto is forbidden. These reach the published +# crate's dependency tree only when a DB is opened with an encryption key, but +# they are unconditional deps (the seal/open code is always compiled). Versions +# pinned to the audited RustCrypto generation current as of 2026-06. +chacha20poly1305 = "0.10" # XChaCha20-Poly1305 AEAD (192-bit nonce) +argon2 = "0.5" # Argon2id passphrase KDF (memory-hard) +hkdf = "0.12" # HKDF-SHA256 raw-key KDF +sha2 = "0.10" # SHA-256 for HKDF +zeroize = { version = "1", features = ["derive"] } # wipe key material on drop +getrandom = "0.2" # OS RNG for DEK / nonce / salt generation [dev-dependencies] tempfile = "3" diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..55f2e03 --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1,27 @@ +// src/crypto/mod.rs — Crypto core (layer 1, no engine coupling). +// +// Standalone at-rest encryption primitives for Chisel: the XChaCha20-Poly1305 +// PageCipher (whole-page + variable-length body seal/open), the envelope KDF +// (HKDF-SHA256 for raw keys, Argon2id for passphrases), DEK wrap/unwrap, and +// the zeroizing key types. Nothing here touches page_io, the cache, or the +// superblock — those layers consume this module in later phases. See +// docs/specs/2026-06-29-on-disk-encryption-design.md §3. +// +// All randomness is OS-sourced (getrandom). Rolling our own crypto is +// forbidden; only the vetted RustCrypto primitives are used. + +// Smoke check that the chacha20poly1305 dep is linked and its key length is +// the 32 bytes the envelope assumes. Replaced by real tests in later tasks. +#[cfg(test)] +mod tests { + #[test] + fn deps_link() { + use chacha20poly1305::KeySizeUser; + use chacha20poly1305::XChaCha20Poly1305; + assert_eq!( + ::key_size(), + 32, + "XChaCha20-Poly1305 key must be 32 bytes" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index d57e961..9bb2cb5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,7 @@ // through the public API) or copying the relevant logic out. pub(crate) mod data_page; pub(crate) mod defrag; +pub(crate) mod crypto; pub(crate) mod error; pub(crate) mod freemap; pub(crate) mod freemap_tree; From 845948e5211eef85e3b1a4d66e98ef35a903e06c Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 19:34:50 -0700 Subject: [PATCH 04/42] feat(error): add encryption error variants (NoEncryptionKey, InvalidEncryptionKey, EncryptionNotSupported, NoFreeKeySlot, LastKeySlot, fatal DecryptionFailed) --- src/error.rs | 111 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/src/error.rs b/src/error.rs index 7dddfcf..399314e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -159,6 +159,39 @@ pub enum ChiselError { stored: u32, compiled: u32, }, + + // Operational — the caller supplied the wrong key material or none, or + // asked an unencrypted-only build to open an encrypted DB. The on-disk + // file is untouched; the caller fixes their `Options` and retries. + // + // Raised at open time when the superblock declares encryption but + // `Options::encryption_key` was None. + NoEncryptionKey, + // Raised at open time when a key was supplied but no key-slot's wrapped + // DEK could be unwrapped under the derived KEK (wrong passphrase / raw + // key). Operational: the DB is intact; supply the right key and reopen. + InvalidEncryptionKey, + // Raised when a key was supplied to open a *plaintext* DB, or an + // encrypted DB is opened by a build that the on-disk crypto-header + // algorithm id is unknown to. Operational: the request is a mismatch, + // not corruption. + EncryptionNotSupported, + // Operational — key-management (add/rotate/remove) ran out of room: all + // KEY_SLOT_COUNT (8) wrapped-DEK slots are occupied, so there is nowhere + // to stage a new credential. The DB is intact; remove an unused key first. + NoFreeKeySlot, + // Operational — refusing to remove the last active key slot, which would + // leave the database with zero usable credentials (permanently unopenable). + LastKeySlot, + // Fatal — an AEAD authentication failure while decrypting a page that + // was already located and read off disk. The ciphertext, tag, nonce, or + // session DEK disagree, so the last-committed snapshot cannot be trusted; + // poisons the manager (I1) exactly like ChecksumMismatch. Distinct from + // InvalidEncryptionKey (a *key-slot* unwrap failure at open, before any + // page is served) — this fires mid-session on a real data/handle page. + DecryptionFailed { + page_id: u64, + }, } impl ChiselError { @@ -190,6 +223,7 @@ impl ChiselError { | ChiselError::CorruptPage { .. } | ChiselError::InvalidPageId { .. } | ChiselError::UnsupportedPageSize { .. } + | ChiselError::DecryptionFailed { .. } ) } } @@ -279,6 +313,29 @@ impl fmt::Display for ChiselError { f, "page size mismatch: file was written with {stored}-byte pages, this build uses {compiled}-byte pages" ), + ChiselError::NoEncryptionKey => write!( + f, + "database is encrypted but no encryption_key was supplied" + ), + ChiselError::InvalidEncryptionKey => write!( + f, + "encryption key does not match any key slot (wrong passphrase or raw key)" + ), + ChiselError::EncryptionNotSupported => write!( + f, + "encryption not supported for this open (key supplied for a plaintext database, or unknown crypto algorithm)" + ), + ChiselError::NoFreeKeySlot => write!( + f, + "no free key slot: all 8 key slots are occupied (remove an unused key first)" + ), + ChiselError::LastKeySlot => write!( + f, + "refusing to remove the last active key slot (the database would become permanently unopenable)" + ), + ChiselError::DecryptionFailed { page_id } => { + write!(f, "decryption/authentication failed for page {page_id}") + } } } } @@ -482,7 +539,14 @@ mod tests { | ChiselError::TagMismatch { .. } // Poisoned is operational by is_fatal()'s definition: the manager // is already dead, so re-seeing it must not re-poison. - | ChiselError::Poisoned => false, + | ChiselError::Poisoned + // Encryption credential errors: the DB is intact; supply the + // right key and retry, or manage key slots before retrying. + | ChiselError::NoEncryptionKey + | ChiselError::InvalidEncryptionKey + | ChiselError::EncryptionNotSupported + | ChiselError::NoFreeKeySlot + | ChiselError::LastKeySlot => false, // Fatal — integrity in question; close and reopen. ChiselError::IoError(_) | ChiselError::ChecksumMismatch { .. } @@ -492,7 +556,8 @@ mod tests { | ChiselError::UnsupportedFormatVersion { .. } | ChiselError::CorruptPage { .. } | ChiselError::InvalidPageId { .. } - | ChiselError::UnsupportedPageSize { .. } => true, + | ChiselError::UnsupportedPageSize { .. } + | ChiselError::DecryptionFailed { .. } => true, } } @@ -537,6 +602,12 @@ mod tests { stored: 0, compiled: 0, }, + ChiselError::NoEncryptionKey, + ChiselError::InvalidEncryptionKey, + ChiselError::EncryptionNotSupported, + ChiselError::NoFreeKeySlot, + ChiselError::LastKeySlot, + ChiselError::DecryptionFailed { page_id: 0 }, ]; for e in &all { assert_eq!( @@ -545,9 +616,41 @@ mod tests { "is_fatal() disagrees with the documented Fatal/Operational block for {e:?}" ); } - // Tripwire: exactly 9 variants are fatal today. If this count moves, the + // Tripwire: exactly 10 variants are fatal today. If this count moves, the // Fatal/Operational split changed — confirm that was intentional (it is a // breaking change for callers doing error-class matching, per the header). - assert_eq!(all.iter().filter(|e| e.is_fatal()).count(), 9); + assert_eq!(all.iter().filter(|e| e.is_fatal()).count(), 10); + } + + // Phase 4: the three operational encryption errors are recoverable (the + // on-disk DB is intact — the caller supplied the wrong/no key, or asked an + // old binary to read a v2 file), so is_fatal() is false. DecryptionFailed + // is fatal: an AEAD tag failure on a page read means the ciphertext or DEK + // is wrong and the snapshot can't be trusted, so it must poison (I1). + #[test] + fn encryption_error_classification() { + assert!(!ChiselError::NoEncryptionKey.is_fatal()); + assert!(!ChiselError::InvalidEncryptionKey.is_fatal()); + assert!(!ChiselError::EncryptionNotSupported.is_fatal()); + assert!(!ChiselError::NoFreeKeySlot.is_fatal()); + assert!(!ChiselError::LastKeySlot.is_fatal()); + assert!(ChiselError::DecryptionFailed { page_id: 7 }.is_fatal()); + + // Display carries the page id for the fatal variant. + let msg = format!("{}", ChiselError::DecryptionFailed { page_id: 7 }); + assert!(msg.contains('7'), "Display {msg:?} should mention page id 7"); + + // source() is None for all four — none wrap an inner cause. + use std::error::Error; + for e in [ + ChiselError::NoEncryptionKey, + ChiselError::InvalidEncryptionKey, + ChiselError::EncryptionNotSupported, + ChiselError::NoFreeKeySlot, + ChiselError::LastKeySlot, + ChiselError::DecryptionFailed { page_id: 0 }, + ] { + assert!(e.source().is_none()); + } } } From 8269f220f28e8869ddadf62b108337a06852fa72 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 19:40:17 -0700 Subject: [PATCH 05/42] feat(crypto): key types, KdfId, Argon2Params, CryptoError, OS randomness --- src/crypto/mod.rs | 180 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 170 insertions(+), 10 deletions(-) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 55f2e03..2d7bfcd 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -10,18 +10,178 @@ // All randomness is OS-sourced (getrandom). Rolling our own crypto is // forbidden; only the vetted RustCrypto primitives are used. -// Smoke check that the chacha20poly1305 dep is linked and its key length is -// the 32 bytes the envelope assumes. Replaced by real tests in later tasks. +// ponytail: staged build — later tasks (PageCipher, KDF, wrap/unwrap) consume +// these types; suppress dead_code until the callers land rather than scattering +// #[allow] on every item. +#![allow(dead_code)] + +use zeroize::Zeroizing; + +/// On-disk stride of one encrypted page: 8192 ciphertext + 16 tag + 24 nonce. +/// The logical page stays 8192 (spec §4.1); only the I/O unit grows. +pub const ENC_PAGE_SIZE: usize = 8232; +/// XChaCha20 nonce length (192 bits). The extended nonce is what makes random +/// per-write nonces safe under shadow-paging page reuse (spec §2.1). +pub const NONCE_LEN: usize = 24; +/// Poly1305 authentication tag length. +pub const TAG_LEN: usize = 16; +/// Data Encryption Key length (256-bit). +pub const DEK_LEN: usize = 32; +/// Per-key-slot KDF salt length. +pub const SALT_LEN: usize = 16; + +/// Client-supplied encryption credential. `Raw` is high-entropy key bytes +/// (derived via HKDF); `Passphrase` is a human secret (derived via Argon2id). +/// Both are zeroized on drop. `Clone` is needed because `Options` is consumed +/// by `open` while rotation APIs may also hold a key. +#[derive(Clone)] +pub enum Key { + Raw(Zeroizing>), + Passphrase(Zeroizing), +} + +/// The Data Encryption Key: seals every page and the superblock body. Generated +/// once at create time, held for the open session only, wiped on drop. Never +/// written to disk except KEK-wrapped in a key-slot. +pub struct Dek(Zeroizing<[u8; DEK_LEN]>); + +impl Dek { + /// Construct from raw bytes (used by unwrap_dek). Kept crate-internal-ish via + /// module visibility; later phases hold a Dek but do not fabricate one. + pub fn from_bytes(bytes: [u8; DEK_LEN]) -> Self { + Dek(Zeroizing::new(bytes)) + } + /// Borrow the raw key bytes. Callers must not copy these into a non-zeroizing + /// buffer that outlives the operation. + pub fn as_bytes(&self) -> &[u8; DEK_LEN] { + &self.0 + } +} + +impl Clone for Dek { + fn clone(&self) -> Self { + Dek(Zeroizing::new(*self.0)) + } +} + +/// The Key Encryption Key: derived per-open from the client key + a slot's +/// salt/params. Only ever wraps/unwraps the DEK; transient, wiped on drop. +pub struct Kek(Zeroizing<[u8; 32]>); + +impl Kek { + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Kek(Zeroizing::new(bytes)) + } + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// KDF selector recorded per key-slot. The integer discriminants are part of +/// the on-disk format (written into the slot's `kdf_id` byte) — do not renumber. +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum KdfId { + Hkdf = 1, + Argon2id = 2, +} + +/// Argon2id cost parameters. Stored per-slot so a slot can be re-derived +/// regardless of the binary's current defaults. +#[derive(Clone, Copy, Debug)] +pub struct Argon2Params { + pub m_cost: u32, // KiB of memory + pub t_cost: u32, // iterations + pub p_cost: u32, // lanes +} + +impl Default for Argon2Params { + /// OWASP-recommended Argon2id baseline (19 MiB, 2 iterations, 1 lane). + fn default() -> Self { + Argon2Params { + m_cost: 19456, + t_cost: 2, + p_cost: 1, + } + } +} + +/// Failures internal to the crypto layer. The engine layer maps these onto +/// ChiselError (Auth → InvalidEncryptionKey/DecryptionFailed depending on site; +/// Kdf/BadKeyLength → operational key errors). PartialEq for ergonomic tests. +#[derive(Debug, PartialEq)] +pub enum CryptoError { + /// AEAD tag verification failed (wrong key, tampered ciphertext, wrong AAD). + Auth, + /// A key-derivation primitive rejected its parameters. + Kdf, + /// A raw key was not the length the KDF requires. + BadKeyLength, +} + +/// Fill an N-byte array from the OS CSPRNG. Panics if the OS RNG is unavailable, +/// which on a supported platform indicates a broken system — there is no safe +/// fallback for key material, so failing loud is correct. +pub fn random_array() -> [u8; N] { + let mut b = [0u8; N]; + getrandom::getrandom(&mut b).expect("OS RNG unavailable"); + b +} + +/// Generate a fresh random DEK from the OS CSPRNG. +pub fn random_dek() -> Dek { + Dek::from_bytes(random_array::()) +} + #[cfg(test)] mod tests { + use super::*; + + #[test] + fn constants_match_spec() { + // On-disk encrypted stride = 8192 ciphertext + 16 tag + 24 nonce. + assert_eq!(ENC_PAGE_SIZE, 8232); + assert_eq!(NONCE_LEN, 24); + assert_eq!(TAG_LEN, 16); + assert_eq!(DEK_LEN, 32); + assert_eq!(SALT_LEN, 16); + assert_eq!(ENC_PAGE_SIZE, 8192 + TAG_LEN + NONCE_LEN); + } + + #[test] + fn argon2_params_default_is_owasp() { + let p = Argon2Params::default(); + assert_eq!(p.m_cost, 19456); // 19 MiB + assert_eq!(p.t_cost, 2); + assert_eq!(p.p_cost, 1); + } + + #[test] + fn kdf_id_discriminants_are_wire_stable() { + // These ints are written into key-slots on disk; pin them. + assert_eq!(KdfId::Hkdf as u8, 1); + assert_eq!(KdfId::Argon2id as u8, 2); + assert_ne!(KdfId::Hkdf, KdfId::Argon2id); + } + + #[test] + fn random_array_is_os_filled_and_distinct() { + let a: [u8; 32] = random_array(); + let b: [u8; 32] = random_array(); + // Astronomically unlikely to collide; all-zero would mean RNG silent-failed. + assert_ne!(a, b); + assert_ne!(a, [0u8; 32]); + } + + #[test] + fn random_dek_differs_each_call() { + let d1 = random_dek(); + let d2 = random_dek(); + assert_ne!(d1.as_bytes(), d2.as_bytes()); + } + #[test] - fn deps_link() { - use chacha20poly1305::KeySizeUser; - use chacha20poly1305::XChaCha20Poly1305; - assert_eq!( - ::key_size(), - 32, - "XChaCha20-Poly1305 key must be 32 bytes" - ); + fn crypto_error_is_comparable() { + assert_eq!(CryptoError::Auth, CryptoError::Auth); + assert_ne!(CryptoError::Auth, CryptoError::Kdf); } } From 758dca26793c3b15f11f8ccac0e026556d3ddaf0 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 19:48:08 -0700 Subject: [PATCH 06/42] feat(crypto): derive_kek dispatching HKDF-SHA256 and Argon2id --- src/crypto/mod.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 2d7bfcd..f8ce092 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -66,6 +66,7 @@ impl Clone for Dek { /// The Key Encryption Key: derived per-open from the client key + a slot's /// salt/params. Only ever wraps/unwraps the DEK; transient, wiped on drop. +#[derive(Debug)] pub struct Kek(Zeroizing<[u8; 32]>); impl Kek { @@ -118,6 +119,51 @@ pub enum CryptoError { BadKeyLength, } +use argon2::{Algorithm, Argon2, Params, Version}; +use hkdf::Hkdf; +use sha2::Sha256; + +/// HKDF info string binding derived KEKs to this construction/version. Changing +/// it is a format break (existing slots would stop unwrapping); versioned so a +/// future KDF revision can coexist. +const KEK_INFO: &[u8] = b"chisel-kek-v1"; + +/// Derive a 256-bit KEK from the client key and a slot's salt/params. +/// +/// Dispatch is on `kdf`, NOT on the `Key` variant: the slot records which KDF +/// produced it, and that is the authority. A `Raw` key is the IKM for HKDF; a +/// `Passphrase` is the password for Argon2id. (A mismatched pairing — e.g. a +/// passphrase with KdfId::Hkdf — still derives a deterministic KEK; it simply +/// won't match the slot that was written with the other KDF, surfacing as an +/// unwrap Auth failure one layer up. The slot's kdf_id is the single source of +/// truth, so we never guess from the variant.) +pub fn derive_kek( + key: &Key, + kdf: KdfId, + salt: &[u8; SALT_LEN], + params: &Argon2Params, +) -> Result { + let ikm: &[u8] = match key { + Key::Raw(bytes) => bytes.as_slice(), + Key::Passphrase(s) => s.as_bytes(), + }; + let mut okm = [0u8; 32]; + match kdf { + KdfId::Hkdf => { + let hk = Hkdf::::new(Some(salt), ikm); + hk.expand(KEK_INFO, &mut okm).map_err(|_| CryptoError::Kdf)?; + } + KdfId::Argon2id => { + let p = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(32)) + .map_err(|_| CryptoError::Kdf)?; + let a2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, p); + a2.hash_password_into(ikm, salt, &mut okm) + .map_err(|_| CryptoError::Kdf)?; + } + } + Ok(Kek::from_bytes(okm)) +} + /// Fill an N-byte array from the OS CSPRNG. Panics if the OS RNG is unavailable, /// which on a supported platform indicates a broken system — there is no safe /// fallback for key material, so failing loud is correct. @@ -184,4 +230,60 @@ mod tests { assert_eq!(CryptoError::Auth, CryptoError::Auth); assert_ne!(CryptoError::Auth, CryptoError::Kdf); } + + #[test] + fn derive_kek_hkdf_matches_reference_construction() { + // RFC 5869 Test Case 1 inputs (IKM/salt), our pinned info string. + let ikm = [0x0bu8; 22]; + let salt: [u8; SALT_LEN] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, + 0x0e, 0x0f, + ]; + let key = Key::Raw(zeroize::Zeroizing::new(ikm.to_vec())); + let kek = derive_kek(&key, KdfId::Hkdf, &salt, &Argon2Params::default()).unwrap(); + + // Independent reference: run hkdf directly with our exact salt+info. + use hkdf::Hkdf; + use sha2::Sha256; + let hk = Hkdf::::new(Some(&salt), &ikm); + let mut expect = [0u8; 32]; + hk.expand(b"chisel-kek-v1", &mut expect).unwrap(); + assert_eq!(kek.as_bytes(), &expect); + } + + #[test] + fn derive_kek_hkdf_is_deterministic_and_salt_sensitive() { + let key = Key::Raw(zeroize::Zeroizing::new(vec![7u8; 32])); + let salt_a = [1u8; SALT_LEN]; + let salt_b = [2u8; SALT_LEN]; + let p = Argon2Params::default(); + let k1 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap(); + let k2 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap(); + let k3 = derive_kek(&key, KdfId::Hkdf, &salt_b, &p).unwrap(); + assert_eq!(k1.as_bytes(), k2.as_bytes(), "same input must be deterministic"); + assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge"); + } + + #[test] + fn derive_kek_argon2_roundtrips_and_is_salt_sensitive() { + // Cheap params so the test is fast (real defaults are 19 MiB). + let fast = Argon2Params { m_cost: 256, t_cost: 1, p_cost: 1 }; + let key = Key::Passphrase(zeroize::Zeroizing::new("correct horse".to_string())); + let salt_a = [9u8; SALT_LEN]; + let salt_b = [8u8; SALT_LEN]; + let k1 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap(); + let k2 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap(); + let k3 = derive_kek(&key, KdfId::Argon2id, &salt_b, &fast).unwrap(); + assert_eq!(k1.as_bytes(), k2.as_bytes(), "Argon2id must be deterministic"); + assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge"); + assert_ne!(k1.as_bytes(), &[0u8; 32]); + } + + #[test] + fn derive_kek_argon2_rejects_zero_memory() { + let bad = Argon2Params { m_cost: 0, t_cost: 1, p_cost: 1 }; + let key = Key::Passphrase(zeroize::Zeroizing::new("x".to_string())); + let err = derive_kek(&key, KdfId::Argon2id, &[0u8; SALT_LEN], &bad).unwrap_err(); + assert_eq!(err, CryptoError::Kdf); + } } From 70f24c59e1d74917aca8648f98cfccc06657463b Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 19:57:30 -0700 Subject: [PATCH 07/42] fix(crypto): prevent key-byte leakage in Kek Debug and KDF output buffer - 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. --- src/crypto/mod.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index f8ce092..c344601 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -66,7 +66,9 @@ impl Clone for Dek { /// The Key Encryption Key: derived per-open from the client key + a slot's /// salt/params. Only ever wraps/unwraps the DEK; transient, wiped on drop. -#[derive(Debug)] +/// +/// No Debug/Display: `Zeroizing`'s derived Debug delegates to the inner array +/// (it does not redact), so printing a Kek would leak the raw key bytes. pub struct Kek(Zeroizing<[u8; 32]>); impl Kek { @@ -147,21 +149,25 @@ pub fn derive_kek( Key::Raw(bytes) => bytes.as_slice(), Key::Passphrase(s) => s.as_bytes(), }; - let mut okm = [0u8; 32]; + // Zeroizing so the derived key never lingers un-wiped on the stack: on the + // success path it is MOVED into Kek (no copy left behind), and on any error + // path partial KDF output is wiped on drop. + let mut okm = Zeroizing::new([0u8; 32]); match kdf { KdfId::Hkdf => { let hk = Hkdf::::new(Some(salt), ikm); - hk.expand(KEK_INFO, &mut okm).map_err(|_| CryptoError::Kdf)?; + hk.expand(KEK_INFO, okm.as_mut()) + .map_err(|_| CryptoError::Kdf)?; } KdfId::Argon2id => { let p = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(32)) .map_err(|_| CryptoError::Kdf)?; let a2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, p); - a2.hash_password_into(ikm, salt, &mut okm) + a2.hash_password_into(ikm, salt, okm.as_mut()) .map_err(|_| CryptoError::Kdf)?; } } - Ok(Kek::from_bytes(okm)) + Ok(Kek(okm)) } /// Fill an N-byte array from the OS CSPRNG. Panics if the OS RNG is unavailable, @@ -283,7 +289,11 @@ mod tests { fn derive_kek_argon2_rejects_zero_memory() { let bad = Argon2Params { m_cost: 0, t_cost: 1, p_cost: 1 }; let key = Key::Passphrase(zeroize::Zeroizing::new("x".to_string())); - let err = derive_kek(&key, KdfId::Argon2id, &[0u8; SALT_LEN], &bad).unwrap_err(); - assert_eq!(err, CryptoError::Kdf); + // matches! rather than unwrap_err: Kek deliberately has no Debug (it + // wraps key bytes), so Result::unwrap_err can't be used here. + assert!(matches!( + derive_kek(&key, KdfId::Argon2id, &[0u8; SALT_LEN], &bad), + Err(CryptoError::Kdf) + )); } } From aa90bc0eaa8b5e9e9e6f4e3b9dc35fd487f4f3d5 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 20:02:15 -0700 Subject: [PATCH 08/42] feat(crypto): DEK wrap/unwrap under KEK with detached XChaCha20-Poly1305 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/crypto/mod.rs | 145 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index c344601..6b42c87 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -170,6 +170,85 @@ pub fn derive_kek( Ok(Kek(okm)) } +use chacha20poly1305::aead::AeadInPlace; +use chacha20poly1305::{Key as AeadKey, KeyInit, XChaCha20Poly1305, XNonce}; + +/// Detached AEAD seal: ciphertext is the same length as plaintext, the 16-byte +/// Poly1305 tag is returned separately. Detached suits our fixed page layout +/// (ciphertext occupies a known 32-byte DEK slot, tag a known 16-byte slot; +/// for pages the same pattern applies with 8192-byte slots). +fn seal_detached( + key: &[u8; 32], + nonce: &[u8; NONCE_LEN], + aad: &[u8], + plaintext: &[u8], +) -> (Vec, [u8; TAG_LEN]) { + let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key)); + let mut buf = plaintext.to_vec(); + let tag = cipher + .encrypt_in_place_detached(XNonce::from_slice(nonce), aad, &mut buf) + .expect("XChaCha20-Poly1305 encrypt cannot fail for in-range lengths"); + let mut tag_arr = [0u8; TAG_LEN]; + tag_arr.copy_from_slice(&tag); + (buf, tag_arr) +} + +/// Detached AEAD open. Any tag mismatch (wrong key, tampered ct/tag, wrong AAD, +/// wrong nonce) maps to CryptoError::Auth. The AEAD impl scrubs the in-place +/// buffer on failure so no partial plaintext escapes. +fn open_detached( + key: &[u8; 32], + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ciphertext: &[u8], + tag: &[u8; TAG_LEN], +) -> Result, CryptoError> { + let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key)); + let mut buf = ciphertext.to_vec(); + cipher + .decrypt_in_place_detached( + XNonce::from_slice(nonce), + aad, + &mut buf, + tag.as_slice().into(), + ) + .map_err(|_| CryptoError::Auth)?; + Ok(buf) +} + +/// Wrap (encrypt) the DEK under a KEK using detached XChaCha20-Poly1305. +/// `aad` binds the slot's metadata (kdf_id, salt, Argon2 params) so an +/// attacker cannot tamper a slot's parameters to force a mis-derivation. +/// Returns (wrapped_dek, wrap_tag); both are written to the key-slot on disk. +pub fn wrap_dek( + kek: &Kek, + dek: &Dek, + wrap_nonce: &[u8; NONCE_LEN], + aad: &[u8], +) -> ([u8; DEK_LEN], [u8; TAG_LEN]) { + let (ct, tag) = seal_detached(kek.as_bytes(), wrap_nonce, aad, dek.as_bytes()); + let mut wrapped = [0u8; DEK_LEN]; + wrapped.copy_from_slice(&ct); + (wrapped, tag) +} + +/// Unwrap (decrypt + authenticate) the DEK. A successful unwrap IS the proof +/// that the client key (hence KEK) is correct — there is no separate verifier. +/// Any failure (wrong passphrase, wrong KEK, tampered ciphertext or tag, wrong +/// AAD) returns CryptoError::Auth without revealing partial plaintext. +pub fn unwrap_dek( + kek: &Kek, + wrapped: &[u8; DEK_LEN], + tag: &[u8; TAG_LEN], + wrap_nonce: &[u8; NONCE_LEN], + aad: &[u8], +) -> Result { + let pt = open_detached(kek.as_bytes(), wrap_nonce, aad, wrapped, tag)?; + let mut dek_bytes = Zeroizing::new([0u8; DEK_LEN]); + dek_bytes.copy_from_slice(&pt); + Ok(Dek::from_bytes(*dek_bytes)) +} + /// Fill an N-byte array from the OS CSPRNG. Panics if the OS RNG is unavailable, /// which on a supported platform indicates a broken system — there is no safe /// fallback for key material, so failing loud is correct. @@ -296,4 +375,70 @@ mod tests { Err(CryptoError::Kdf) )); } + + #[test] + fn wrap_unwrap_roundtrip() { + let kek = Kek::from_bytes([3u8; 32]); + let dek = Dek::from_bytes([42u8; DEK_LEN]); + let nonce = [5u8; NONCE_LEN]; + let aad = b"slot-meta"; + let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, aad); + assert_ne!(&wrapped, dek.as_bytes(), "wrapped DEK must not equal plaintext DEK"); + let out = unwrap_dek(&kek, &wrapped, &tag, &nonce, aad).unwrap(); + assert_eq!(out.as_bytes(), dek.as_bytes()); + } + + #[test] + fn unwrap_wrong_kek_is_auth() { + let dek = Dek::from_bytes([42u8; DEK_LEN]); + let nonce = [5u8; NONCE_LEN]; + let aad = b"slot-meta"; + let (wrapped, tag) = wrap_dek(&Kek::from_bytes([3u8; 32]), &dek, &nonce, aad); + // unwrap_err() requires Dek: Debug, which we deliberately omit (it wraps key + // bytes). Use matches! to test the error variant without printing anything. + assert!(matches!( + unwrap_dek(&Kek::from_bytes([4u8; 32]), &wrapped, &tag, &nonce, aad), + Err(CryptoError::Auth) + )); + } + + #[test] + fn unwrap_tampered_tag_is_auth() { + let kek = Kek::from_bytes([3u8; 32]); + let dek = Dek::from_bytes([42u8; DEK_LEN]); + let nonce = [5u8; NONCE_LEN]; + let aad = b"slot-meta"; + let (wrapped, mut tag) = wrap_dek(&kek, &dek, &nonce, aad); + tag[0] ^= 0x01; + assert!(matches!( + unwrap_dek(&kek, &wrapped, &tag, &nonce, aad), + Err(CryptoError::Auth) + )); + } + + #[test] + fn unwrap_tampered_ciphertext_is_auth() { + let kek = Kek::from_bytes([3u8; 32]); + let dek = Dek::from_bytes([42u8; DEK_LEN]); + let nonce = [5u8; NONCE_LEN]; + let aad = b"slot-meta"; + let (mut wrapped, tag) = wrap_dek(&kek, &dek, &nonce, aad); + wrapped[0] ^= 0x01; + assert!(matches!( + unwrap_dek(&kek, &wrapped, &tag, &nonce, aad), + Err(CryptoError::Auth) + )); + } + + #[test] + fn unwrap_wrong_aad_is_auth() { + let kek = Kek::from_bytes([3u8; 32]); + let dek = Dek::from_bytes([42u8; DEK_LEN]); + let nonce = [5u8; NONCE_LEN]; + let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, b"slot-meta-A"); + assert!(matches!( + unwrap_dek(&kek, &wrapped, &tag, &nonce, b"slot-meta-B"), + Err(CryptoError::Auth) + )); + } } From a02b2ecc852468df3d6e6ead9017614b20482c21 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 20:08:57 -0700 Subject: [PATCH 09/42] fix(crypto): zeroize decrypted DEK plaintext from open_detached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open_detached returned a plain Vec 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> 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. --- src/crypto/mod.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 6b42c87..e527e63 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -15,6 +15,8 @@ // #[allow] on every item. #![allow(dead_code)] +use chacha20poly1305::aead::AeadInPlace; +use chacha20poly1305::{Key as AeadKey, KeyInit, XChaCha20Poly1305, XNonce}; use zeroize::Zeroizing; /// On-disk stride of one encrypted page: 8192 ciphertext + 16 tag + 24 nonce. @@ -170,9 +172,6 @@ pub fn derive_kek( Ok(Kek(okm)) } -use chacha20poly1305::aead::AeadInPlace; -use chacha20poly1305::{Key as AeadKey, KeyInit, XChaCha20Poly1305, XNonce}; - /// Detached AEAD seal: ciphertext is the same length as plaintext, the 16-byte /// Poly1305 tag is returned separately. Detached suits our fixed page layout /// (ciphertext occupies a known 32-byte DEK slot, tag a known 16-byte slot; @@ -194,17 +193,21 @@ fn seal_detached( } /// Detached AEAD open. Any tag mismatch (wrong key, tampered ct/tag, wrong AAD, -/// wrong nonce) maps to CryptoError::Auth. The AEAD impl scrubs the in-place -/// buffer on failure so no partial plaintext escapes. +/// wrong nonce) maps to CryptoError::Auth. XChaCha20-Poly1305 is verify-then- +/// decrypt: `decrypt_in_place_detached` checks the Poly1305 tag BEFORE applying +/// the keystream, so on auth failure the buffer still holds the original +/// ciphertext and no plaintext is ever written. The returned buffer is +/// Zeroizing so the decrypted plaintext (key material) is wiped on drop rather +/// than handed back to the allocator un-scrubbed. fn open_detached( key: &[u8; 32], nonce: &[u8; NONCE_LEN], aad: &[u8], ciphertext: &[u8], tag: &[u8; TAG_LEN], -) -> Result, CryptoError> { +) -> Result>, CryptoError> { let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key)); - let mut buf = ciphertext.to_vec(); + let mut buf = Zeroizing::new(ciphertext.to_vec()); cipher .decrypt_in_place_detached( XNonce::from_slice(nonce), From 196c04e660f0da9c0dd50e88aa202e307eb34cb5 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 20:12:37 -0700 Subject: [PATCH 10/42] feat(crypto): PageCipher whole-page and body seal/open --- src/crypto/mod.rs | 140 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index e527e63..bdcb7ab 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -266,6 +266,72 @@ pub fn random_dek() -> Dek { Dek::from_bytes(random_array::()) } +/// Holds the DEK and performs the two seal/open transforms the engine needs: +/// whole-page (fixed 8192→8232) and variable-length body (superblock sub-blob). +/// Lives in the page-cache layer in later phases; here it is fully standalone. +/// Constructs the AEAD cipher once and reuses it across calls. +pub struct PageCipher { + dek: Dek, +} + +impl PageCipher { + pub fn new(dek: Dek) -> Self { + PageCipher { dek } + } + + /// Seal a full 8192-byte plaintext page image into the 8232-byte on-disk + /// blob: `ciphertext(8192) ‖ tag(16) ‖ nonce(24)`. AAD = page_id LE bytes + /// (anti-relocation). A fresh random 192-bit nonce per call (spec §2.1) — + /// safe under shadow-paging page reuse, and stored in the clear. + pub fn seal(&self, page_id: u64, plaintext: &[u8; 8192]) -> [u8; ENC_PAGE_SIZE] { + let nonce = random_array::(); + let aad = page_id.to_le_bytes(); + let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, &aad, plaintext); + let mut out = [0u8; ENC_PAGE_SIZE]; + out[0..8192].copy_from_slice(&ct); + out[8192..8208].copy_from_slice(&tag); + out[8208..8232].copy_from_slice(&nonce); + out + } + + /// Open an 8232-byte on-disk blob back to the 8192-byte plaintext page. + /// AAD = page_id LE. Any authentication failure → CryptoError::Auth (the + /// engine maps this to DecryptionFailed at the page-read site). + pub fn open(&self, page_id: u64, ondisk: &[u8; ENC_PAGE_SIZE]) -> Result<[u8; 8192], CryptoError> { + let ct = &ondisk[0..8192]; + let mut tag = [0u8; TAG_LEN]; + tag.copy_from_slice(&ondisk[8192..8208]); + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(&ondisk[8208..8232]); + let aad = page_id.to_le_bytes(); + let pt = open_detached(self.dek.as_bytes(), &nonce, &aad, ct, &tag)?; + let mut page = [0u8; 8192]; + page.copy_from_slice(&pt); + Ok(page) + } + + /// Seal a variable-length body (the superblock sensitive sub-blob). Returns + /// (nonce, tag, ciphertext); the caller lays these out in the reserved + /// region. AAD binds the body to the superblock's identity (anti-splicing). + pub fn seal_body(&self, aad: &[u8], plaintext: &[u8]) -> ([u8; NONCE_LEN], [u8; TAG_LEN], Vec) { + let nonce = random_array::(); + let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, aad, plaintext); + (nonce, tag, ct) + } + + /// Open a variable-length body sealed by `seal_body`. AAD must match the + /// superblock identity used at seal time, else CryptoError::Auth. + pub fn open_body( + &self, + aad: &[u8], + nonce: &[u8; NONCE_LEN], + tag: &[u8; TAG_LEN], + ct: &[u8], + ) -> Result, CryptoError> { + open_detached(self.dek.as_bytes(), nonce, aad, ct, tag).map(|z| z.to_vec()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -444,4 +510,78 @@ mod tests { Err(CryptoError::Auth) )); } + + #[test] + fn page_seal_open_roundtrip() { + let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN])); + let mut page = [0u8; 8192]; + for (i, b) in page.iter_mut().enumerate() { + *b = (i % 251) as u8; + } + let blob = pc.seal(7, &page); + assert_eq!(blob.len(), ENC_PAGE_SIZE); + let out = pc.open(7, &blob).unwrap(); + assert_eq!(out, page); + } + + #[test] + fn page_seal_layout_is_ct_tag_nonce() { + let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN])); + let page = [0xABu8; 8192]; + let blob = pc.seal(0, &page); + // ciphertext occupies 0..8192, tag 8192..8208, nonce 8208..8232. + assert_ne!(&blob[0..8192], &page[..], "ciphertext must differ from plaintext"); + } + + #[test] + fn page_open_wrong_page_id_is_auth() { + // AAD = page_id gives anti-relocation: a page sealed at id 7 must not + // authenticate at id 8. + let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN])); + let page = [9u8; 8192]; + let blob = pc.seal(7, &page); + assert_eq!(pc.open(8, &blob).unwrap_err(), CryptoError::Auth); + } + + #[test] + fn page_open_byte_flip_is_auth() { + let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN])); + let page = [9u8; 8192]; + let mut blob = pc.seal(7, &page); + blob[100] ^= 0x01; // flip a ciphertext byte + assert_eq!(pc.open(7, &blob).unwrap_err(), CryptoError::Auth); + } + + #[test] + fn page_two_seals_use_different_nonces() { + // Random per-write nonce (spec §2.1): two seals of the same page must + // produce different on-disk blobs (different nonce ⇒ different ct+tag). + let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN])); + let page = [9u8; 8192]; + let a = pc.seal(7, &page); + let b = pc.seal(7, &page); + assert_ne!(&a[..], &b[..], "nonce reuse: identical blobs for same page"); + // Both still open correctly. + assert_eq!(pc.open(7, &a).unwrap(), page); + assert_eq!(pc.open(7, &b).unwrap(), page); + } + + #[test] + fn body_seal_open_roundtrip() { + let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN])); + let body = b"root pointers + named_roots".to_vec(); + let aad = b"sb-identity"; + let (nonce, tag, ct) = pc.seal_body(aad, &body); + assert_eq!(ct.len(), body.len(), "body cipher is length-preserving"); + let out = pc.open_body(aad, &nonce, &tag, &ct).unwrap(); + assert_eq!(out, body); + } + + #[test] + fn body_open_wrong_aad_is_auth() { + let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN])); + let body = b"secret".to_vec(); + let (nonce, tag, ct) = pc.seal_body(b"sb-A", &body); + assert_eq!(pc.open_body(b"sb-B", &nonce, &tag, &ct).unwrap_err(), CryptoError::Auth); + } } From 9b1fa71e7677c18b77b426b452456e6f7b22d946 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 20:20:16 -0700 Subject: [PATCH 11/42] test(crypto): zeroization guards; clippy/fmt clean for crypto core --- src/crypto/mod.rs | 78 +++++++++++++++++++++++++++++++++++++++++------ src/error.rs | 5 ++- src/lib.rs | 2 +- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index bdcb7ab..bb80dc1 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -297,7 +297,11 @@ impl PageCipher { /// Open an 8232-byte on-disk blob back to the 8192-byte plaintext page. /// AAD = page_id LE. Any authentication failure → CryptoError::Auth (the /// engine maps this to DecryptionFailed at the page-read site). - pub fn open(&self, page_id: u64, ondisk: &[u8; ENC_PAGE_SIZE]) -> Result<[u8; 8192], CryptoError> { + pub fn open( + &self, + page_id: u64, + ondisk: &[u8; ENC_PAGE_SIZE], + ) -> Result<[u8; 8192], CryptoError> { let ct = &ondisk[0..8192]; let mut tag = [0u8; TAG_LEN]; tag.copy_from_slice(&ondisk[8192..8208]); @@ -313,7 +317,11 @@ impl PageCipher { /// Seal a variable-length body (the superblock sensitive sub-blob). Returns /// (nonce, tag, ciphertext); the caller lays these out in the reserved /// region. AAD binds the body to the superblock's identity (anti-splicing). - pub fn seal_body(&self, aad: &[u8], plaintext: &[u8]) -> ([u8; NONCE_LEN], [u8; TAG_LEN], Vec) { + pub fn seal_body( + &self, + aad: &[u8], + plaintext: &[u8], + ) -> ([u8; NONCE_LEN], [u8; TAG_LEN], Vec) { let nonce = random_array::(); let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, aad, plaintext); (nonce, tag, ct) @@ -414,28 +422,44 @@ mod tests { let k1 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap(); let k2 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap(); let k3 = derive_kek(&key, KdfId::Hkdf, &salt_b, &p).unwrap(); - assert_eq!(k1.as_bytes(), k2.as_bytes(), "same input must be deterministic"); + assert_eq!( + k1.as_bytes(), + k2.as_bytes(), + "same input must be deterministic" + ); assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge"); } #[test] fn derive_kek_argon2_roundtrips_and_is_salt_sensitive() { // Cheap params so the test is fast (real defaults are 19 MiB). - let fast = Argon2Params { m_cost: 256, t_cost: 1, p_cost: 1 }; + let fast = Argon2Params { + m_cost: 256, + t_cost: 1, + p_cost: 1, + }; let key = Key::Passphrase(zeroize::Zeroizing::new("correct horse".to_string())); let salt_a = [9u8; SALT_LEN]; let salt_b = [8u8; SALT_LEN]; let k1 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap(); let k2 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap(); let k3 = derive_kek(&key, KdfId::Argon2id, &salt_b, &fast).unwrap(); - assert_eq!(k1.as_bytes(), k2.as_bytes(), "Argon2id must be deterministic"); + assert_eq!( + k1.as_bytes(), + k2.as_bytes(), + "Argon2id must be deterministic" + ); assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge"); assert_ne!(k1.as_bytes(), &[0u8; 32]); } #[test] fn derive_kek_argon2_rejects_zero_memory() { - let bad = Argon2Params { m_cost: 0, t_cost: 1, p_cost: 1 }; + let bad = Argon2Params { + m_cost: 0, + t_cost: 1, + p_cost: 1, + }; let key = Key::Passphrase(zeroize::Zeroizing::new("x".to_string())); // matches! rather than unwrap_err: Kek deliberately has no Debug (it // wraps key bytes), so Result::unwrap_err can't be used here. @@ -452,7 +476,11 @@ mod tests { let nonce = [5u8; NONCE_LEN]; let aad = b"slot-meta"; let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, aad); - assert_ne!(&wrapped, dek.as_bytes(), "wrapped DEK must not equal plaintext DEK"); + assert_ne!( + &wrapped, + dek.as_bytes(), + "wrapped DEK must not equal plaintext DEK" + ); let out = unwrap_dek(&kek, &wrapped, &tag, &nonce, aad).unwrap(); assert_eq!(out.as_bytes(), dek.as_bytes()); } @@ -530,7 +558,11 @@ mod tests { let page = [0xABu8; 8192]; let blob = pc.seal(0, &page); // ciphertext occupies 0..8192, tag 8192..8208, nonce 8208..8232. - assert_ne!(&blob[0..8192], &page[..], "ciphertext must differ from plaintext"); + assert_ne!( + &blob[0..8192], + &page[..], + "ciphertext must differ from plaintext" + ); } #[test] @@ -582,6 +614,34 @@ mod tests { let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN])); let body = b"secret".to_vec(); let (nonce, tag, ct) = pc.seal_body(b"sb-A", &body); - assert_eq!(pc.open_body(b"sb-B", &nonce, &tag, &ct).unwrap_err(), CryptoError::Auth); + assert_eq!( + pc.open_body(b"sb-B", &nonce, &tag, &ct).unwrap_err(), + CryptoError::Auth + ); + } + + // Zeroization guards: verify that Dek/Key wrap Zeroizing buffers and that + // Clone produces independent copies (so dropping one does not corrupt the + // other). We cannot observe freed memory in safe Rust, so the honest check + // is independence of cloned buffers — if the inner Zeroizing zeroes-on-drop + // the original's view is unaffected because they own separate allocations. + #[test] + fn dek_clone_is_independent_zeroizing_copy() { + let d = Dek::from_bytes([7u8; DEK_LEN]); + let c = d.clone(); + assert_eq!(d.as_bytes(), c.as_bytes()); + // Dropping the clone must not affect the original (independent buffers). + drop(c); + assert_eq!(d.as_bytes(), &[7u8; DEK_LEN]); + } + + #[test] + fn key_variants_construct_from_zeroizing() { + // Compile + construct proof that Key wraps Zeroizing for both variants. + let raw = Key::Raw(zeroize::Zeroizing::new(vec![1u8, 2, 3])); + let pass = Key::Passphrase(zeroize::Zeroizing::new("pw".to_string())); + // Clone works (needed by Options/rotation). + let _r2 = raw.clone(); + let _p2 = pass.clone(); } } diff --git a/src/error.rs b/src/error.rs index 399314e..dca82fc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -638,7 +638,10 @@ mod tests { // Display carries the page id for the fatal variant. let msg = format!("{}", ChiselError::DecryptionFailed { page_id: 7 }); - assert!(msg.contains('7'), "Display {msg:?} should mention page id 7"); + assert!( + msg.contains('7'), + "Display {msg:?} should mention page id 7" + ); // source() is None for all four — none wrap an inner cause. use std::error::Error; diff --git a/src/lib.rs b/src/lib.rs index 9bb2cb5..e67bda1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,9 +36,9 @@ // them from a downstream crate requires either a path-dep with // #[cfg(test)] access (the bench subcrate does this implicitly // through the public API) or copying the relevant logic out. +pub(crate) mod crypto; pub(crate) mod data_page; pub(crate) mod defrag; -pub(crate) mod crypto; pub(crate) mod error; pub(crate) mod freemap; pub(crate) mod freemap_tree; From 8e9ca003376f93ff4b43081201fda2fa13cf75c9 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 20:31:27 -0700 Subject: [PATCH 12/42] feat(superblock): add crypto-header key-slot table in the reserved region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/crypto/mod.rs | 2 +- src/superblock/crypto_header.rs | 210 +++++++++++++++++++++++ src/{superblock.rs => superblock/mod.rs} | 7 + 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/superblock/crypto_header.rs rename src/{superblock.rs => superblock/mod.rs} (99%) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index bb80dc1..b7c3321 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -92,7 +92,7 @@ pub enum KdfId { /// Argon2id cost parameters. Stored per-slot so a slot can be re-derived /// regardless of the binary's current defaults. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Argon2Params { pub m_cost: u32, // KiB of memory pub t_cost: u32, // iterations diff --git a/src/superblock/crypto_header.rs b/src/superblock/crypto_header.rs new file mode 100644 index 0000000..ee9c7f0 --- /dev/null +++ b/src/superblock/crypto_header.rs @@ -0,0 +1,210 @@ +// superblock/crypto_header.rs — the plaintext crypto-header that lives in the +// superblock's reserved region for encrypted databases. Holds the algorithm id, +// the on-disk page stride, and the 8-slot key-slot table (each slot wraps the +// per-DB DEK under a KEK derived from one client key). For PLAINTEXT databases +// the reserved region stays zeroed and `deserialize` returns None (algorithm 0). +// +// No callers yet outside this file's own tests; will be wired into superblock +// open/create in Phase 2.2. +#![allow(dead_code)] +// +// On-disk layout (all inside the superblock's reserved region, after freemap_depth): +// 324..325 algorithm (u8; 1 = XChaCha20-Poly1305, 0 = none/plaintext) +// 325..329 stride (u32 LE; 8232 for encrypted, validated by the engine) +// 329..332 reserved (zero) +// 332..332+8*128 the 8 key-slot records, 128 bytes each +// Total = 8 + 8*128 = 1032 bytes, ending at 1356 — well inside CHECKSUM_OFFSET (8184). +// +// Key-slot record (128 bytes; trailing bytes reserved/zero): +// 0 state (u8; 1 = active, 0 = empty) +// 1 kdf_id (u8; 1 = HKDF, 2 = Argon2id) +// 2..14 argon2 params: m_cost(u32) | t_cost(u32) | p_cost(u32) (zero for HKDF) +// 14..30 salt (16) +// 30..54 wrap_nonce (24) +// 54..86 wrapped_dek (32) +// 86..102 wrap_tag (16) +// 102..128 reserved + +use crate::crypto::{Argon2Params, DEK_LEN, NONCE_LEN, SALT_LEN, TAG_LEN}; +use crate::page::{self, PAGE_SIZE}; + +pub const KEY_SLOT_COUNT: usize = 8; +pub const KEY_SLOT_SIZE: usize = 128; +// Immediately after freemap_depth (bytes 320..324). Keep in lockstep with +// superblock/mod.rs's FREEMAP_DEPTH_OFFSET (320) + 4. +pub const CRYPTO_HEADER_OFFSET: usize = 324; +pub const CRYPTO_HEADER_SIZE: usize = 8 + KEY_SLOT_COUNT * KEY_SLOT_SIZE; + +const SLOT_TABLE_OFFSET: usize = CRYPTO_HEADER_OFFSET + 8; +// Compile-time proof that the crypto header fits inside the reserved region. +const _: () = assert!(CRYPTO_HEADER_OFFSET + CRYPTO_HEADER_SIZE <= page::CHECKSUM_OFFSET); + +/// Algorithm id stored in the header. 0 means "no encryption" (plaintext DB); +/// the only supported nonzero value today is 1 = XChaCha20-Poly1305. +pub const ALGO_XCHACHA20POLY1305: u8 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeySlot { + pub state: u8, + pub kdf_id: u8, + pub argon2: Argon2Params, + pub salt: [u8; SALT_LEN], + pub wrap_nonce: [u8; NONCE_LEN], + pub wrapped_dek: [u8; DEK_LEN], + pub wrap_tag: [u8; TAG_LEN], +} + +impl KeySlot { + pub const EMPTY: KeySlot = KeySlot { + state: 0, + kdf_id: 0, + argon2: Argon2Params { m_cost: 0, t_cost: 0, p_cost: 0 }, + salt: [0u8; SALT_LEN], + wrap_nonce: [0u8; NONCE_LEN], + wrapped_dek: [0u8; DEK_LEN], + wrap_tag: [0u8; TAG_LEN], + }; + + /// True if this slot holds a usable wrapped DEK (state byte == 1). + pub fn is_active(&self) -> bool { + self.state == 1 + } + + /// The bytes an unwrap operation must authenticate as AAD: the slot's own + /// metadata up to but excluding the wrapped_dek/tag. Binds the wrap to its + /// salt/params/nonce so a slot can't be transplanted between DBs. + pub fn aad(&self) -> [u8; 1 + 1 + 12 + SALT_LEN + NONCE_LEN] { + let mut a = [0u8; 1 + 1 + 12 + SALT_LEN + NONCE_LEN]; + a[0] = self.state; + a[1] = self.kdf_id; + a[2..6].copy_from_slice(&self.argon2.m_cost.to_le_bytes()); + a[6..10].copy_from_slice(&self.argon2.t_cost.to_le_bytes()); + a[10..14].copy_from_slice(&self.argon2.p_cost.to_le_bytes()); + a[14..14 + SALT_LEN].copy_from_slice(&self.salt); + a[14 + SALT_LEN..14 + SALT_LEN + NONCE_LEN].copy_from_slice(&self.wrap_nonce); + a + } + + fn write_into(&self, slot: &mut [u8]) { + slot[0] = self.state; + slot[1] = self.kdf_id; + slot[2..6].copy_from_slice(&self.argon2.m_cost.to_le_bytes()); + slot[6..10].copy_from_slice(&self.argon2.t_cost.to_le_bytes()); + slot[10..14].copy_from_slice(&self.argon2.p_cost.to_le_bytes()); + slot[14..14 + SALT_LEN].copy_from_slice(&self.salt); + slot[30..30 + NONCE_LEN].copy_from_slice(&self.wrap_nonce); + slot[54..54 + DEK_LEN].copy_from_slice(&self.wrapped_dek); + slot[86..86 + TAG_LEN].copy_from_slice(&self.wrap_tag); + } + + fn read_from(slot: &[u8]) -> KeySlot { + let mut k = KeySlot::EMPTY; + k.state = slot[0]; + k.kdf_id = slot[1]; + k.argon2 = Argon2Params { + m_cost: u32::from_le_bytes(slot[2..6].try_into().unwrap()), + t_cost: u32::from_le_bytes(slot[6..10].try_into().unwrap()), + p_cost: u32::from_le_bytes(slot[10..14].try_into().unwrap()), + }; + k.salt.copy_from_slice(&slot[14..14 + SALT_LEN]); + k.wrap_nonce.copy_from_slice(&slot[30..30 + NONCE_LEN]); + k.wrapped_dek.copy_from_slice(&slot[54..54 + DEK_LEN]); + k.wrap_tag.copy_from_slice(&slot[86..86 + TAG_LEN]); + k + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CryptoHeader { + pub algorithm: u8, + pub stride: u32, + pub slots: [KeySlot; KEY_SLOT_COUNT], +} + +impl CryptoHeader { + /// Write the crypto-header into the superblock's reserved region. Touches + /// only [CRYPTO_HEADER_OFFSET, CRYPTO_HEADER_OFFSET+CRYPTO_HEADER_SIZE); + /// the caller stamps the page checksum afterward. + pub fn serialize_into(&self, buf: &mut [u8; PAGE_SIZE]) { + buf[CRYPTO_HEADER_OFFSET] = self.algorithm; + buf[CRYPTO_HEADER_OFFSET + 1..CRYPTO_HEADER_OFFSET + 5] + .copy_from_slice(&self.stride.to_le_bytes()); + for (i, slot) in self.slots.iter().enumerate() { + let base = SLOT_TABLE_OFFSET + i * KEY_SLOT_SIZE; + slot.write_into(&mut buf[base..base + KEY_SLOT_SIZE]); + } + } + + /// Read the crypto-header. Returns None for a plaintext DB (algorithm byte + /// 0), which is how callers distinguish "encrypted" from "plaintext". + pub fn deserialize(buf: &[u8; PAGE_SIZE]) -> Option { + let algorithm = buf[CRYPTO_HEADER_OFFSET]; + if algorithm == 0 { + return None; + } + let stride = u32::from_le_bytes( + buf[CRYPTO_HEADER_OFFSET + 1..CRYPTO_HEADER_OFFSET + 5] + .try_into() + .unwrap(), + ); + let mut slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + for (i, slot) in slots.iter_mut().enumerate() { + let base = SLOT_TABLE_OFFSET + i * KEY_SLOT_SIZE; + *slot = KeySlot::read_from(&buf[base..base + KEY_SLOT_SIZE]); + } + Some(CryptoHeader { algorithm, stride, slots }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::Argon2Params; + use crate::page::{self, PAGE_SIZE}; + + fn sample_slot(state: u8) -> KeySlot { + KeySlot { + state, + kdf_id: 1, + argon2: Argon2Params { m_cost: 19456, t_cost: 2, p_cost: 1 }, + salt: [7u8; 16], + wrap_nonce: [9u8; 24], + wrapped_dek: [3u8; 32], + wrap_tag: [5u8; 16], + } + } + + #[test] + fn crypto_header_round_trips_through_reserved_region() { + let mut slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + slots[0] = sample_slot(1); // active + slots[3] = sample_slot(1); // active + let header = CryptoHeader { algorithm: 1, stride: 8232, slots }; + + let mut buf = [0u8; PAGE_SIZE]; + header.serialize_into(&mut buf); + + // Bytes before the header (the existing fields + reserved gap up to 324) + // are NOT touched by serialize_into. + assert_eq!(buf[..CRYPTO_HEADER_OFFSET], [0u8; CRYPTO_HEADER_OFFSET][..]); + + let back = CryptoHeader::deserialize(&buf).expect("active header must deserialize"); + assert_eq!(back.algorithm, 1); + assert_eq!(back.stride, 8232); + assert!(back.slots[0].is_active()); + assert!(!back.slots[1].is_active()); + assert!(back.slots[3].is_active()); + assert_eq!(back.slots[0].salt, [7u8; 16]); + assert_eq!(back.slots[0].wrap_nonce, [9u8; 24]); + assert_eq!(back.slots[0].wrapped_dek, [3u8; 32]); + assert_eq!(back.slots[0].wrap_tag, [5u8; 16]); + assert_eq!(back.slots[0].argon2.m_cost, 19456); + } + + #[test] + fn deserialize_returns_none_for_plaintext_db() { + // A zeroed reserved region (plaintext DB) has algorithm == 0 -> None. + let buf = [0u8; PAGE_SIZE]; + assert!(CryptoHeader::deserialize(&buf).is_none()); + } +} diff --git a/src/superblock.rs b/src/superblock/mod.rs similarity index 99% rename from src/superblock.rs rename to src/superblock/mod.rs index e0472e9..7a1cb25 100644 --- a/src/superblock.rs +++ b/src/superblock/mod.rs @@ -34,6 +34,13 @@ use crate::page::{self, MAGIC, PAGE_SIZE}; use std::fmt; +mod crypto_header; +#[allow(unused_imports)] // Phase 2.2+ wires these into superblock serialize/open +pub use crypto_header::{ + CryptoHeader, KeySlot, ALGO_XCHACHA20POLY1305, CRYPTO_HEADER_OFFSET, CRYPTO_HEADER_SIZE, + KEY_SLOT_COUNT, KEY_SLOT_SIZE, +}; + // Superblock count bounds (ISSUES.md R4). Hardcoded limits keep the // probe-at-open-time cost bounded and prevent obviously-broken configs. // N=1 is disqualified because it provides no redundancy — a single torn From 44e9aa2568ed4fa51b565454c9fc7b249b54ad94 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 20:46:01 -0700 Subject: [PATCH 13/42] feat(superblock): DEK-sealed body and Superblock.encryption field Adds `pub encryption: Option` 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`. --- src/recovery_tests.rs | 3 + src/superblock/crypto_header.rs | 8 +- src/superblock/mod.rs | 295 +++++++++++++++++++++++++++++++- src/transaction/commit.rs | 4 + 4 files changed, 306 insertions(+), 4 deletions(-) diff --git a/src/recovery_tests.rs b/src/recovery_tests.rs index 768bd2b..e15e17a 100644 --- a/src/recovery_tests.rs +++ b/src/recovery_tests.rs @@ -600,6 +600,7 @@ fn test_recovery_superblock_pointing_past_eof_is_rejected() { superblock_count: crate::superblock::DEFAULT_SUPERBLOCK_COUNT, root_membership_index_page: page::PAGE_ID_NONE, freemap_depth: 0, + encryption: None, }; let buf = sb.serialize(); { @@ -654,6 +655,7 @@ fn test_recovery_superblock_total_pages_max_is_rejected_not_panic() { superblock_count: crate::superblock::DEFAULT_SUPERBLOCK_COUNT, root_membership_index_page: page::PAGE_ID_NONE, freemap_depth: 0, + encryption: None, }; let buf = sb.serialize(); { @@ -770,6 +772,7 @@ fn test_reject_unsupported_format_version() { superblock_count: crate::superblock::DEFAULT_SUPERBLOCK_COUNT, root_membership_index_page: page::PAGE_ID_NONE, freemap_depth: 0, + encryption: None, }; let buf_a = sb.serialize(); sb.txn_counter = 4; diff --git a/src/superblock/crypto_header.rs b/src/superblock/crypto_header.rs index ee9c7f0..2ecbfa4 100644 --- a/src/superblock/crypto_header.rs +++ b/src/superblock/crypto_header.rs @@ -4,8 +4,10 @@ // per-DB DEK under a KEK derived from one client key). For PLAINTEXT databases // the reserved region stays zeroed and `deserialize` returns None (algorithm 0). // -// No callers yet outside this file's own tests; will be wired into superblock -// open/create in Phase 2.2. +// Consumed by superblock/mod.rs (serialize_encrypted, deserialize, decrypt_body). +// ponytail: items here are called from serialize_encrypted / decrypt_body which +// themselves are dead-code-warned at the superblock level pending Phase 2.4 +// wiring; suppress until that caller lands. #![allow(dead_code)] // // On-disk layout (all inside the superblock's reserved region, after freemap_depth): @@ -160,7 +162,7 @@ impl CryptoHeader { mod tests { use super::*; use crate::crypto::Argon2Params; - use crate::page::{self, PAGE_SIZE}; + use crate::page::PAGE_SIZE; fn sample_slot(state: u8) -> KeySlot { KeySlot { diff --git a/src/superblock/mod.rs b/src/superblock/mod.rs index 7a1cb25..6cf1c4b 100644 --- a/src/superblock/mod.rs +++ b/src/superblock/mod.rs @@ -31,11 +31,15 @@ // writes. N is stored inside each superblock so open-time recovery can // discover it from the first valid slot. +use crate::crypto::{CryptoError, NONCE_LEN, TAG_LEN}; use crate::page::{self, MAGIC, PAGE_SIZE}; use std::fmt; mod crypto_header; -#[allow(unused_imports)] // Phase 2.2+ wires these into superblock serialize/open +// Re-export the crypto-header API for consumers (open/create code in later +// phases, key-management tools, tests). Items not yet referenced in non-test +// module code are still public API surface — the allow is intentional. +#[allow(unused_imports)] pub use crypto_header::{ CryptoHeader, KeySlot, ALGO_XCHACHA20POLY1305, CRYPTO_HEADER_OFFSET, CRYPTO_HEADER_SIZE, KEY_SLOT_COUNT, KEY_SLOT_SIZE, @@ -219,6 +223,12 @@ pub struct Superblock { // is a COW radix tree of FreeMap bitmap leaves with FreeMapInterior inner // nodes; root_freemap_page points to the root at that depth. pub freemap_depth: u32, + /// Crypto-header for an encrypted database. `None` for plaintext DBs, in + /// which case serialize/deserialize use the existing all-plaintext layout. + /// `Some` means the sensitive fields are sealed in a DEK-encrypted body + /// sub-blob; those fields are zero until the caller supplies the DEK and + /// calls `decrypt_body`. + pub encryption: Option, } /// The three torn-slot rules, shared by the hot path (`deserialize`) and the @@ -244,6 +254,34 @@ fn validate(buf: &[u8; PAGE_SIZE]) -> Result<(), SuperblockDefect> { Ok(()) } +// Offset where the DEK-sealed body sub-blob starts, immediately after the +// crypto-header's key-slot table. Layout of the sealed region: +// SEALED_BODY_OFFSET .. +24 nonce (XChaCha20 192-bit) +// +24 .. +40 Poly1305 authentication tag +// +40 .. +42 ciphertext length (u16 LE) +// +42 .. +42+ct_len ciphertext +// The sealed region must fit before CHECKSUM_OFFSET (8184); at the maximum +// body length (see BODY_LEN) the region ends well inside that bound. +pub const SEALED_BODY_OFFSET: usize = + crypto_header::CRYPTO_HEADER_OFFSET + crypto_header::CRYPTO_HEADER_SIZE; // 1356 + +// Plaintext body layout (the bytes fed to seal_body): the sensitive fields in +// a fixed order. +// 0..8 root_handle_table_page (u64 LE) +// 8..16 root_freemap_page (u64 LE) +// 16..24 root_membership_index_page (u64 LE) +// 24..32 total_pages (u64 LE) +// 32..40 next_handle (u64 LE) +// 40..44 freemap_depth (u32 LE) +// 44..44+NAMED_ROOT_COUNT*NAMED_ROOT_ENTRY_SIZE named_roots +const BODY_LEN: usize = 8 * 5 + 4 + (NAMED_ROOT_COUNT * NAMED_ROOT_ENTRY_SIZE); + +// Compile-time check: the sealed blob fits before the checksum. +// SEALED_BODY_OFFSET(1356) + NONCE_LEN(24) + TAG_LEN(16) + 2(len) + BODY_LEN. +const _: () = assert!( + SEALED_BODY_OFFSET + NONCE_LEN + TAG_LEN + 2 + BODY_LEN <= page::CHECKSUM_OFFSET +); + impl Superblock { /// Serialize the superblock into a full page buffer with a trailing checksum. /// The offsets below are part of the on-disk format contract — do not @@ -284,6 +322,128 @@ impl Superblock { buf } + // ponytail: methods below are called from serialize_encrypted/decrypt_body + // which in turn are called from tests and will be wired to the commit/open + // path in Task 2.4. Suppress dead_code until that caller lands. + #[allow(dead_code)] + /// Build the AAD that binds the sealed body and each key-slot's DEK wrap to + /// this superblock's plaintext identity. The four bootstrap fields that stay + /// cleartext in both encrypted and plaintext DBs are included; this prevents + /// transplanting a sealed body from a different DB or a different txn_counter. + pub fn sb_identity_aad(&self) -> [u8; 24] { + let mut a = [0u8; 24]; + a[0..4].copy_from_slice(&self.magic.to_le_bytes()); + a[4..8].copy_from_slice(&self.format_version.to_le_bytes()); + a[8..16].copy_from_slice(&self.txn_counter.to_le_bytes()); + a[16..20].copy_from_slice(&self.superblock_count.to_le_bytes()); + // bytes 20..24 are reserved (zero) for future AAD fields. + a + } + + /// Assemble the plaintext body for sealing: all sensitive fields in the + /// canonical order defined by BODY_LEN. Called only for encrypted DBs. + fn body_plaintext(&self) -> Vec { + let mut b = Vec::with_capacity(BODY_LEN); + b.extend_from_slice(&self.root_handle_table_page.to_le_bytes()); + b.extend_from_slice(&self.root_freemap_page.to_le_bytes()); + b.extend_from_slice(&self.root_membership_index_page.to_le_bytes()); + b.extend_from_slice(&self.total_pages.to_le_bytes()); + b.extend_from_slice(&self.next_handle.to_le_bytes()); + b.extend_from_slice(&self.freemap_depth.to_le_bytes()); + for entry in &self.named_roots { + b.extend_from_slice(&entry.name); + b.extend_from_slice(&entry.handle.to_le_bytes()); + } + b + } + + /// Unpack a decrypted body blob into `self`'s sensitive fields. The body + /// layout must match `body_plaintext`'s encoding. + fn load_body(&mut self, body: &[u8]) { + self.root_handle_table_page = u64::from_le_bytes(body[0..8].try_into().unwrap()); + self.root_freemap_page = u64::from_le_bytes(body[8..16].try_into().unwrap()); + self.root_membership_index_page = u64::from_le_bytes(body[16..24].try_into().unwrap()); + self.total_pages = u64::from_le_bytes(body[24..32].try_into().unwrap()); + self.next_handle = u64::from_le_bytes(body[32..40].try_into().unwrap()); + self.freemap_depth = u32::from_le_bytes(body[40..44].try_into().unwrap()); + let mut off = 44; + for entry in self.named_roots.iter_mut() { + entry.name.copy_from_slice(&body[off..off + NAMED_ROOT_NAME_LEN]); + entry.handle = u64::from_le_bytes( + body[off + NAMED_ROOT_NAME_LEN..off + NAMED_ROOT_NAME_LEN + 8] + .try_into() + .unwrap(), + ); + off += NAMED_ROOT_ENTRY_SIZE; + } + } + + /// Serialize an encrypted superblock: bootstrap fields + crypto-header in + /// cleartext; sensitive fields sealed under the DEK. The byte ranges that + /// would hold sensitive data in a plaintext page are left ZERO so nothing + /// leaks (named_roots at 52..308, root/page-id scalars at 16..52, etc.). + /// + /// Panics if `self.encryption` is `None` — only call for encrypted DBs. + #[allow(dead_code)] + pub fn serialize_encrypted(&self, cipher: &crate::crypto::PageCipher) -> [u8; PAGE_SIZE] { + let header = self + .encryption + .as_ref() + .expect("serialize_encrypted requires Superblock.encryption = Some"); + let mut buf = [0u8; PAGE_SIZE]; + // Plaintext bootstrap fields only. Sensitive scalar fields (16..52) + // and named_roots (52..308) are intentionally left zero. + buf[0..4].copy_from_slice(&self.magic.to_le_bytes()); + buf[4..8].copy_from_slice(&self.format_version.to_le_bytes()); + buf[8..16].copy_from_slice(&self.txn_counter.to_le_bytes()); + buf[48..52].copy_from_slice(&self.page_size.to_le_bytes()); + buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4] + .copy_from_slice(&self.superblock_count.to_le_bytes()); + // Crypto-header written into reserved region (plaintext). + header.serialize_into(&mut buf); + // Seal the sensitive body into the region immediately after the + // key-slot table: nonce || tag || ct_len(u16 LE) || ciphertext. + let aad = self.sb_identity_aad(); + let (nonce, tag, ct) = cipher.seal_body(&aad, &self.body_plaintext()); + let base = SEALED_BODY_OFFSET; + buf[base..base + NONCE_LEN].copy_from_slice(&nonce); + buf[base + NONCE_LEN..base + NONCE_LEN + TAG_LEN].copy_from_slice(&tag); + buf[base + NONCE_LEN + TAG_LEN..base + NONCE_LEN + TAG_LEN + 2] + .copy_from_slice(&(ct.len() as u16).to_le_bytes()); + let coff = base + NONCE_LEN + TAG_LEN + 2; + buf[coff..coff + ct.len()].copy_from_slice(&ct); + page::stamp_checksum(&mut buf); + buf + } + + /// Decrypt the sealed body into `self`'s sensitive fields. Caller must have + /// already called `deserialize` (which fills bootstrap fields and the + /// crypto-header from cleartext) and obtained the matching DEK. Returns + /// `CryptoError::Auth` if the DEK or AAD is wrong, or the blob is tampered. + #[allow(dead_code)] + pub fn decrypt_body( + &mut self, + cipher: &crate::crypto::PageCipher, + raw: &[u8; PAGE_SIZE], + ) -> Result<(), CryptoError> { + let base = SEALED_BODY_OFFSET; + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(&raw[base..base + NONCE_LEN]); + let mut tag = [0u8; TAG_LEN]; + tag.copy_from_slice(&raw[base + NONCE_LEN..base + NONCE_LEN + TAG_LEN]); + let ct_len = u16::from_le_bytes( + raw[base + NONCE_LEN + TAG_LEN..base + NONCE_LEN + TAG_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let coff = base + NONCE_LEN + TAG_LEN + 2; + let ct = &raw[coff..coff + ct_len]; + let aad = self.sb_identity_aad(); + let body = cipher.open_body(&aad, &nonce, &tag, ct)?; + self.load_body(&body); + Ok(()) + } + /// Deserialize from a page buffer. Returns None if the checksum is invalid /// or the magic number doesn't match. /// @@ -301,6 +461,33 @@ impl Superblock { // the compiled PAGE_SIZE is a fatal open-time error the caller raises, // not a torn-slot signal that should make select() fall back. validate(buf).ok()?; + // Check for an encryption header first. For encrypted DBs only the + // bootstrap fields are in cleartext; the sensitive fields stay zero + // until the caller supplies the DEK and calls `decrypt_body`. + let encryption = crypto_header::CryptoHeader::deserialize(buf); + if encryption.is_some() { + let superblock_count = u32::from_le_bytes( + buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4] + .try_into() + .unwrap(), + ); + return Some(Superblock { + magic: u32::from_le_bytes(buf[0..4].try_into().unwrap()), + format_version: u32::from_le_bytes(buf[4..8].try_into().unwrap()), + txn_counter: u64::from_le_bytes(buf[8..16].try_into().unwrap()), + root_handle_table_page: 0, + root_freemap_page: 0, + total_pages: 0, + next_handle: 0, + page_size: u32::from_le_bytes(buf[48..52].try_into().unwrap()), + named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT], + superblock_count, + root_membership_index_page: 0, + freemap_depth: 0, + encryption, + }); + } + // Plaintext path: all fields are directly readable. let mut named_roots = [NamedRoot::EMPTY; NAMED_ROOT_COUNT]; for (i, entry) in named_roots.iter_mut().enumerate() { let base = NAMED_ROOTS_OFFSET + i * NAMED_ROOT_ENTRY_SIZE; @@ -339,6 +526,7 @@ impl Superblock { .try_into() .unwrap(), ), + encryption: None, }) } @@ -454,6 +642,7 @@ impl Superblock { superblock_count, root_membership_index_page: page::PAGE_ID_NONE, freemap_depth: 0, + encryption: None, } } } @@ -587,6 +776,7 @@ mod tests { superblock_count: DEFAULT_SUPERBLOCK_COUNT, root_membership_index_page: crate::page::PAGE_ID_NONE, freemap_depth: 0, + encryption: None, }; let buf = sb.serialize(); let sb2 = Superblock::deserialize(&buf).unwrap(); @@ -608,6 +798,7 @@ mod tests { superblock_count: DEFAULT_SUPERBLOCK_COUNT, root_membership_index_page: crate::page::PAGE_ID_NONE, freemap_depth: 0, + encryption: None, }; let mut buf = sb.serialize(); buf[10] ^= 0xFF; @@ -629,6 +820,7 @@ mod tests { superblock_count: DEFAULT_SUPERBLOCK_COUNT, root_membership_index_page: crate::page::PAGE_ID_NONE, freemap_depth: 0, + encryption: None, }; let sb2 = Superblock { magic: MAGIC, @@ -643,6 +835,7 @@ mod tests { superblock_count: DEFAULT_SUPERBLOCK_COUNT, root_membership_index_page: crate::page::PAGE_ID_NONE, freemap_depth: 0, + encryption: None, }; let buf1 = sb1.serialize(); let buf2 = sb2.serialize(); @@ -665,6 +858,7 @@ mod tests { superblock_count: DEFAULT_SUPERBLOCK_COUNT, root_membership_index_page: crate::page::PAGE_ID_NONE, freemap_depth: 0, + encryption: None, }; let sb2_buf = [0u8; PAGE_SIZE]; let buf1 = sb1.serialize(); @@ -728,6 +922,103 @@ mod tests { // page_size). NamedRoot.name uses a byte-array strategy so the // empty-slot convention (name[0] == 0) gets exercised alongside // populated names. + // ── Encrypted-superblock tests (Task 2.2) ── + + /// Full encrypt→serialize→deserialize→decrypt round-trip: sensitive fields + /// must survive the seal/open cycle and must not appear in the raw bytes. + #[test] + fn encrypted_superblock_hides_sensitive_fields_and_round_trips() { + use crate::crypto::{random_dek, PageCipher}; + + let cipher = PageCipher::new(random_dek()); + let mut header_slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + header_slots[0].state = 1; // mark one slot active (simulates a real key-slot) + let header = CryptoHeader { + algorithm: ALGO_XCHACHA20POLY1305, + stride: 8232, + slots: header_slots, + }; + + let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT); + sb.root_handle_table_page = 7; + sb.next_handle = 99; + sb.total_pages = 41; + sb.named_roots[0].name[..5].copy_from_slice(b"users"); + sb.named_roots[0].handle = 12345; + sb.encryption = Some(header); + + let buf = sb.serialize_encrypted(&cipher); + + // Sensitive bytes must be absent from cleartext. + // named_roots occupy 52..308; all must be zero in the encrypted page. + assert_eq!(&buf[52..308], &[0u8; 256][..], "named_roots leaked in cleartext"); + // Scalar sensitive fields at 16..48 must be zero. + assert_eq!(&buf[16..48], &[0u8; 32][..], "sensitive scalars leaked"); + // Bootstrap fields stay plaintext. + assert_eq!(u32::from_le_bytes(buf[0..4].try_into().unwrap()), MAGIC); + assert_eq!( + u64::from_le_bytes(buf[8..16].try_into().unwrap()), + sb.txn_counter + ); + + // Two-phase deserialize: sensitive fields are zero after deserialize. + let mut back = Superblock::deserialize(&buf).expect("encrypted sb deserializes"); + assert!(back.encryption.is_some(), "encryption field must be populated"); + assert_eq!(back.root_handle_table_page, 0, "not yet decrypted"); + assert_eq!(back.next_handle, 0, "not yet decrypted"); + + // After decrypt_body the sensitive fields are restored. + back.decrypt_body(&cipher, &buf).expect("DEK opens body"); + assert_eq!(back.root_handle_table_page, 7); + assert_eq!(back.next_handle, 99); + assert_eq!(back.total_pages, 41); + assert_eq!(&back.named_roots[0].name[..5], b"users"); + assert_eq!(back.named_roots[0].handle, 12345); + } + + /// Wrong DEK must produce a CryptoError (authentication failure), not + /// silently corrupt the sensitive fields. + #[test] + fn wrong_dek_fails_body_authentication() { + use crate::crypto::{random_dek, PageCipher}; + + let cipher = PageCipher::new(random_dek()); + let mut header_slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + header_slots[0].state = 1; + let header = CryptoHeader { + algorithm: ALGO_XCHACHA20POLY1305, + stride: 8232, + slots: header_slots, + }; + let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT); + sb.encryption = Some(header); + let buf = sb.serialize_encrypted(&cipher); + + let wrong = PageCipher::new(random_dek()); + let mut back = Superblock::deserialize(&buf).unwrap(); + assert!(back.decrypt_body(&wrong, &buf).is_err()); + } + + /// Plaintext DBs must serialize byte-identically to the pre-encryption + /// implementation (regression guard: `encryption: None` path is unchanged). + #[test] + fn plaintext_superblock_round_trips_unchanged() { + let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT); + sb.root_handle_table_page = 5; + sb.root_freemap_page = 6; + sb.total_pages = 20; + sb.next_handle = 3; + sb.named_roots[0].name[..4].copy_from_slice(b"test"); + sb.named_roots[0].handle = 42; + + let buf = sb.serialize(); + let back = Superblock::deserialize(&buf).expect("plaintext must deserialize"); + assert!(back.encryption.is_none()); + assert_eq!(back.root_handle_table_page, 5); + assert_eq!(back.named_roots[0].handle, 42); + assert_eq!(&back.named_roots[0].name[..4], b"test"); + } + proptest::proptest! { #[test] fn prop_serialize_deserialize_roundtrip( @@ -768,6 +1059,7 @@ mod tests { superblock_count, root_membership_index_page, freemap_depth: 0, + encryption: None, }; let buf = sb.serialize(); let parsed = Superblock::deserialize(&buf) @@ -786,6 +1078,7 @@ mod tests { prop_assert_eq!(parsed.superblock_count, sb.superblock_count); prop_assert_eq!(parsed.root_membership_index_page, sb.root_membership_index_page); prop_assert_eq!(parsed.freemap_depth, sb.freemap_depth); + prop_assert_eq!(parsed.encryption, sb.encryption); for i in 0..NAMED_ROOT_COUNT { prop_assert_eq!(parsed.named_roots[i].name, sb.named_roots[i].name); prop_assert_eq!(parsed.named_roots[i].handle, sb.named_roots[i].handle); diff --git a/src/transaction/commit.rs b/src/transaction/commit.rs index 46db23d..b30cc8e 100644 --- a/src/transaction/commit.rs +++ b/src/transaction/commit.rs @@ -131,6 +131,10 @@ pub(super) fn run_commit(ctx: &mut CommitCtx<'_>) -> Result<()> { // Freemap tree depth, paired with root_freemap_page. 0 = today's // single-leaf format; grows as the tree deepens. freemap_depth: ctx.current_roots.freemap_depth, + // Encryption header: None for plaintext DBs (the current path). + // Task 2.4 will populate this from the open-time session context + // for encrypted DBs and switch to serialize_encrypted. + encryption: None, }; let buf = sb.serialize(); // Step 3: Write to the INACTIVE slot. For N superblock slots, From 700f0d05bd8a10aadc9b3a066e4e54c20e0eb605 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 20:54:35 -0700 Subject: [PATCH 14/42] fix(superblock): bounds-check sealed-body ct_len before slicing 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. --- src/superblock/mod.rs | 46 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/superblock/mod.rs b/src/superblock/mod.rs index 6cf1c4b..a6efb2c 100644 --- a/src/superblock/mod.rs +++ b/src/superblock/mod.rs @@ -360,6 +360,9 @@ impl Superblock { /// Unpack a decrypted body blob into `self`'s sensitive fields. The body /// layout must match `body_plaintext`'s encoding. fn load_body(&mut self, body: &[u8]) { + // open_body returns exactly the plaintext that was sealed, which is + // always BODY_LEN bytes (see body_plaintext); document the invariant. + debug_assert_eq!(body.len(), BODY_LEN); self.root_handle_table_page = u64::from_le_bytes(body[0..8].try_into().unwrap()); self.root_freemap_page = u64::from_le_bytes(body[8..16].try_into().unwrap()); self.root_membership_index_page = u64::from_le_bytes(body[16..24].try_into().unwrap()); @@ -437,6 +440,14 @@ impl Superblock { .unwrap(), ) as usize; let coff = base + NONCE_LEN + TAG_LEN + 2; + // Bounds-check ct_len before slicing. The page checksum is XXH3 (non- + // cryptographic): an attacker who edits the page can recompute it, so a + // forged ct_len must NOT reach the slice and panic. Treat an out-of- + // range length as an undecryptable body — same Err as a failed AEAD + // auth, since open_body would never accept it anyway. + if coff + ct_len > page::CHECKSUM_OFFSET { + return Err(CryptoError::Auth); + } let ct = &raw[coff..coff + ct_len]; let aad = self.sb_identity_aad(); let body = cipher.open_body(&aad, &nonce, &tag, ct)?; @@ -954,6 +965,10 @@ mod tests { assert_eq!(&buf[52..308], &[0u8; 256][..], "named_roots leaked in cleartext"); // Scalar sensitive fields at 16..48 must be zero. assert_eq!(&buf[16..48], &[0u8; 32][..], "sensitive scalars leaked"); + // root_membership_index_page (312..320) and freemap_depth (320..324) + // are also sealed-only, so their plaintext slots must be zero. Bytes + // 308..312 (superblock_count) are legitimately cleartext and skipped. + assert_eq!(&buf[312..324], &[0u8; 12][..], "membership/freemap_depth leaked"); // Bootstrap fields stay plaintext. assert_eq!(u32::from_le_bytes(buf[0..4].try_into().unwrap()), MAGIC); assert_eq!( @@ -999,6 +1014,37 @@ mod tests { assert!(back.decrypt_body(&wrong, &buf).is_err()); } + /// A forged ct_len (the XXH3 page checksum is non-cryptographic, so it + /// cannot protect it) must surface as a recoverable Err, never a panic on + /// the slice. Regression guard for the out-of-bounds slice fixed in review. + #[test] + fn forged_ct_len_returns_err_not_panic() { + use crate::crypto::{random_dek, PageCipher}; + + let cipher = PageCipher::new(random_dek()); + let mut header_slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + header_slots[0].state = 1; + let header = CryptoHeader { + algorithm: ALGO_XCHACHA20POLY1305, + stride: 8232, + slots: header_slots, + }; + let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT); + sb.encryption = Some(header); + let mut buf = sb.serialize_encrypted(&cipher); + + // Overwrite the 2-byte ct_len field with 0xFFFF (65535), which would + // slice far past CHECKSUM_OFFSET, then re-stamp the checksum so the + // page validates (simulating an attacker who recomputed XXH3). + let len_off = SEALED_BODY_OFFSET + NONCE_LEN + TAG_LEN; + buf[len_off..len_off + 2].copy_from_slice(&0xFFFFu16.to_le_bytes()); + page::stamp_checksum(&mut buf); + + let mut back = Superblock::deserialize(&buf).unwrap(); + // Must return Err, not panic. + assert!(back.decrypt_body(&cipher, &buf).is_err()); + } + /// Plaintext DBs must serialize byte-identically to the pre-encryption /// implementation (regression guard: `encryption: None` path is unchanged). #[test] From ccd523b478cc5116620a99fa21c28828d49ed84f Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Mon, 29 Jun 2026 21:12:03 -0700 Subject: [PATCH 15/42] =?UTF-8?q?feat(engine):=20create=20encrypted=20data?= =?UTF-8?q?base=20=E2=80=94=20wrap=20DEK=20into=20slot=200,=20stamp=20MAJO?= =?UTF-8?q?R=3D2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/crypto/mod.rs | 28 ++++ src/defrag.rs | 2 +- src/error.rs | 14 ++ src/lib.rs | 32 ++++- src/page.rs | 10 ++ src/superblock/mod.rs | 22 ++++ src/transaction/mod.rs | 7 + src/transaction/recovery.rs | 82 +++++++++++- src/transaction/tests.rs | 16 +-- tests/encryption_create.rs | 253 ++++++++++++++++++++++++++++++++++++ 10 files changed, 450 insertions(+), 16 deletions(-) create mode 100644 tests/encryption_create.rs diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index b7c3321..a1087f4 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -42,6 +42,17 @@ pub enum Key { Passphrase(Zeroizing), } +// Debug intentionally omits the key bytes — key material must not appear in +// logs, panic messages, or error chains. The variant name is enough for diagnostics. +impl std::fmt::Debug for Key { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Key::Raw(_) => f.write_str("Key::Raw()"), + Key::Passphrase(_) => f.write_str("Key::Passphrase()"), + } + } +} + /// The Data Encryption Key: seals every page and the superblock body. Generated /// once at create time, held for the open session only, wiped on drop. Never /// written to disk except KEK-wrapped in a key-slot. @@ -141,6 +152,11 @@ const KEK_INFO: &[u8] = b"chisel-kek-v1"; /// won't match the slot that was written with the other KDF, surfacing as an /// unwrap Auth failure one layer up. The slot's kdf_id is the single source of /// truth, so we never guess from the variant.) +/// +/// # Errors +/// Returns `CryptoError::Kdf` if the KDF primitive rejects its parameters +/// (e.g. Argon2id with zero memory cost). Returns `CryptoError::BadKeyLength` +/// if `kdf == Hkdf` and the raw key bytes are empty. pub fn derive_kek( key: &Key, kdf: KdfId, @@ -239,6 +255,10 @@ pub fn wrap_dek( /// that the client key (hence KEK) is correct — there is no separate verifier. /// Any failure (wrong passphrase, wrong KEK, tampered ciphertext or tag, wrong /// AAD) returns CryptoError::Auth without revealing partial plaintext. +/// +/// # Errors +/// Returns `CryptoError::Auth` if AEAD authentication fails (wrong key, +/// tampered ciphertext, wrong AAD, or wrong nonce). pub fn unwrap_dek( kek: &Kek, wrapped: &[u8; DEK_LEN], @@ -297,6 +317,10 @@ impl PageCipher { /// Open an 8232-byte on-disk blob back to the 8192-byte plaintext page. /// AAD = page_id LE. Any authentication failure → CryptoError::Auth (the /// engine maps this to DecryptionFailed at the page-read site). + /// + /// # Errors + /// Returns `CryptoError::Auth` if the AEAD tag does not verify (wrong DEK, + /// tampered ciphertext, or mismatched page_id). pub fn open( &self, page_id: u64, @@ -329,6 +353,10 @@ impl PageCipher { /// Open a variable-length body sealed by `seal_body`. AAD must match the /// superblock identity used at seal time, else CryptoError::Auth. + /// + /// # Errors + /// Returns `CryptoError::Auth` if the AEAD tag does not verify (wrong DEK + /// or AAD, or tampered ciphertext). pub fn open_body( &self, aad: &[u8], diff --git a/src/defrag.rs b/src/defrag.rs index 58887ed..c4447d8 100644 --- a/src/defrag.rs +++ b/src/defrag.rs @@ -286,7 +286,7 @@ mod tests { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut tm = TransactionManager::create_new(cache, 2).unwrap(); + let mut tm = TransactionManager::create_new(cache, 2, None).unwrap(); tm.begin().unwrap(); tm.commit().unwrap(); tm diff --git a/src/error.rs b/src/error.rs index dca82fc..217f907 100644 --- a/src/error.rs +++ b/src/error.rs @@ -385,6 +385,20 @@ impl From for ChiselError { } } +impl From for ChiselError { + // A CryptoError reaching the engine through `?` in the create/open/key-management + // paths is always a key-or-KDF problem on intact on-disk data, so it maps to the + // operational InvalidEncryptionKey. The page-read path (Phase 3) does NOT use this + // blanket conversion — it maps decrypt failures explicitly to the fatal + // ChiselError::DecryptionFailed { page_id } via `.map_err(...)`. The operational + // cases that are NOT CryptoError-derived (no key supplied for an encrypted DB; a + // key supplied for a plaintext DB) are returned explicitly as NoEncryptionKey / + // EncryptionNotSupported at their decision sites. + fn from(_: crate::crypto::CryptoError) -> Self { + ChiselError::InvalidEncryptionKey + } +} + /// Crate-wide Result alias. All fallible Chisel APIs return this. pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index e67bda1..01206c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,8 +74,14 @@ pub use handle::{Handle, Tag, TagDropProgress}; pub use page::PAGE_SIZE; pub use stats::{ChiselCounters, Stats}; pub use superblock::{ - SlotDefect, SuperblockDefect, DEFAULT_SUPERBLOCK_COUNT, MAX_SUPERBLOCKS, MIN_SUPERBLOCKS, - NAMED_ROOT_COUNT, NAMED_ROOT_NAME_LEN, + CryptoHeader, KeySlot, SlotDefect, SuperblockDefect, ALGO_XCHACHA20POLY1305, + CRYPTO_HEADER_OFFSET, DEFAULT_SUPERBLOCK_COUNT, KEY_SLOT_COUNT, KEY_SLOT_SIZE, + MAX_SUPERBLOCKS, MIN_SUPERBLOCKS, NAMED_ROOT_COUNT, NAMED_ROOT_NAME_LEN, +}; +pub use page::{FORMAT_MAJOR_VERSION_ENCRYPTED, format_major, format_version_encrypted}; +pub use crypto::{ + derive_kek, unwrap_dek, wrap_dek, Argon2Params, KdfId, Key, PageCipher, CryptoError, + NONCE_LEN, SALT_LEN, DEK_LEN, }; use std::path::Path; @@ -138,6 +144,9 @@ pub struct Options { pub create_if_missing: bool, pub read_only: bool, pub superblock_count: u32, + /// Encryption key supplied at open/create. `Some` creates (or opens) an + /// encrypted database; `None` keeps the existing plaintext format. + pub(crate) encryption_key: Option, } /// Where commit-drain rehydrated pages are inserted into the LRU. @@ -188,6 +197,7 @@ impl Default for Options { create_if_missing: true, read_only: false, superblock_count: superblock::DEFAULT_SUPERBLOCK_COUNT, + encryption_key: None, } } } @@ -235,6 +245,15 @@ impl Options { self.superblock_count = count; self } + + /// Supply an encryption key. On create, a fresh DEK is generated and + /// wrapped into key-slot 0 under a KEK derived from this key; the + /// superblock is stamped MAJOR=2. On open (Task 2.4), the key is used + /// to unwrap the stored DEK from the matching slot. + pub fn with_encryption_key(mut self, key: crate::crypto::Key) -> Self { + self.encryption_key = Some(key); + self + } } /// A live handle to an open Chisel database. @@ -344,7 +363,11 @@ impl Chisel { // superblock. options.superblock_count is ignored here. TransactionManager::open_existing(cache)? } else { - TransactionManager::create_new(cache, options.superblock_count)? + TransactionManager::create_new( + cache, + options.superblock_count, + options.encryption_key.clone(), + )? }; Ok(Chisel { txm }) @@ -402,7 +425,8 @@ impl Chisel { options.drain_insertion, SpillwayLocation::InMemory, ); - let txm = TransactionManager::create_new(cache, options.superblock_count)?; + // ponytail: in-memory path never encrypts (no key supplied at this call site) + let txm = TransactionManager::create_new(cache, options.superblock_count, None)?; Ok(Chisel { txm }) } diff --git a/src/page.rs b/src/page.rs index dfbb888..7515077 100644 --- a/src/page.rs +++ b/src/page.rs @@ -113,6 +113,16 @@ pub const MAGIC: u32 = 0x4348534C; // "CHSL" pub const FORMAT_MAJOR_VERSION: u16 = 1; pub const FORMAT_MINOR_VERSION: u16 = 1; +/// MAJOR version stamped into an ENCRYPTED database's superblock. The bump from +/// 1 → 2 hard-rejects old binaries (which gate on FORMAT_MAJOR_VERSION == 1). +pub const FORMAT_MAJOR_VERSION_ENCRYPTED: u16 = 2; + +/// Pack the encrypted-DB format version: MAJOR=2, MINOR=current. Used by +/// `create_new` when a key is supplied, and by Task 2.4's open-time gate. +pub fn format_version_encrypted() -> u32 { + pack_format_version(FORMAT_MAJOR_VERSION_ENCRYPTED, FORMAT_MINOR_VERSION) +} + /// Pack a (major, minor) pair into the on-disk u32 format version. pub const fn pack_format_version(major: u16, minor: u16) -> u32 { ((major as u32) << 16) | (minor as u32) diff --git a/src/superblock/mod.rs b/src/superblock/mod.rs index a6efb2c..204abdc 100644 --- a/src/superblock/mod.rs +++ b/src/superblock/mod.rs @@ -656,6 +656,28 @@ impl Superblock { encryption: None, } } + + /// Like `new_empty`, but stamps MAJOR=2 and embeds the crypto-header so + /// `serialize_encrypted` can seal the body. Called exclusively from the + /// `create_new` encrypted path; the `CryptoHeader` carries the wrapped DEK + /// in slot 0 and is written in cleartext into the superblock's reserved region. + pub fn new_empty_encrypted(superblock_count: u32, header: CryptoHeader) -> Superblock { + Superblock { + magic: MAGIC, + format_version: page::format_version_encrypted(), + txn_counter: (superblock_count - 1) as u64, + root_handle_table_page: page::PAGE_ID_NONE, + root_freemap_page: page::PAGE_ID_NONE, + total_pages: superblock_count as u64, + next_handle: 1, + page_size: PAGE_SIZE as u32, + named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT], + superblock_count, + root_membership_index_page: page::PAGE_ID_NONE, + freemap_depth: 0, + encryption: Some(header), + } + } } #[cfg(test)] diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index f94a342..8e8d438 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -215,6 +215,13 @@ pub struct TransactionManager { // AtomicBool because TransactionManager is !Sync by design (see // lib.rs); there is no cross-thread access to synchronize against. poisoned: Cell, + /// Per-session page cipher for an encrypted database. `None` for plaintext. + /// Holds the unwrapped DEK (zeroizing) for the life of the manager; reaches + /// the PageCache in Phase 3 for per-page seal/open. Set on the create path + /// (fresh DEK) and on the open path when Task 2.4 lands (DEK unwrapped from + /// a key-slot). The DEK inside PageCipher is zeroizing and is cleared on drop. + #[allow(dead_code)] // used by create path; Phase 3 wires it to page I/O + cipher: Option, // Test-only fault injection consolidated off the production type (review // 2026-06-22 SMELL #4): the four BUG#2 atomic-staging arming flags live in diff --git a/src/transaction/recovery.rs b/src/transaction/recovery.rs index fab45eb..cdace86 100644 --- a/src/transaction/recovery.rs +++ b/src/transaction/recovery.rs @@ -29,7 +29,11 @@ impl TransactionManager { /// filters on XXH3 checksum validity BEFORE comparing counters — /// a legitimate counter-0 slot has a valid checksum; a zeroed /// region doesn't. - pub fn create_new(mut cache: PageCache, superblock_count: u32) -> Result { + pub fn create_new( + mut cache: PageCache, + superblock_count: u32, + key: Option, + ) -> Result { // Caller is expected to have validated bounds via Options in // lib.rs, but defend against direct-call misuse too. assert!( @@ -37,6 +41,14 @@ impl TransactionManager { "superblock_count {superblock_count} out of supported range 2..=16" ); + // Build the per-session cipher up front for an encrypted DB: a fresh + // random DEK is sealed into slot 0 under a KEK derived from `key`. + // For plaintext DBs this is None and the slot-write loop uses serialize(). + let cipher = match key { + None => None, + Some(k) => Some(build_create_cipher(&k)?), + }; + // Write N staggered slots. Slot 0 gets the highest counter // (superblock_count - 1), slot N-1 gets 0. First user commit // bumps to N, which modulo N is 0, so slot 0 is the first to @@ -48,10 +60,16 @@ impl TransactionManager { // `select()` at open time will pick slot 0 (highest counter). // After the first user commit, slot 0 holds the newest data // and the rest remain as "rollback fallbacks". - let mut sb = Superblock::new_empty(superblock_count); + let mut sb = match &cipher { + None => Superblock::new_empty(superblock_count), + Some(cc) => Superblock::new_empty_encrypted(superblock_count, cc.header), + }; for i in 0..superblock_count { sb.txn_counter = (superblock_count - 1 - i) as u64; - let buf = sb.serialize(); + let buf = match &cipher { + None => sb.serialize(), + Some(cc) => sb.serialize_encrypted(&cc.page_cipher), + }; cache.io_mut().write_page(i as u64, &buf)?; } cache.io_mut().fsync()?; @@ -93,6 +111,7 @@ impl TransactionManager { // A fresh database has no data pages and no live slots yet. packer: packing::SlotPacker::new(), poisoned: Cell::new(false), + cipher: cipher.map(|cc| cc.page_cipher), #[cfg(test)] fault: fault::FaultInjector::default(), }) @@ -313,8 +332,65 @@ impl TransactionManager { freemap: freemap::FreemapRecycle::new(), packer: packing::SlotPacker::from_committed(committed_live_slots), poisoned: Cell::new(false), + // Task 2.4 fills this from the supplied key + the on-disk slot. + cipher: None, #[cfg(test)] fault: fault::FaultInjector::default(), }) } } + +/// Output of the create-time key setup: the live cipher for this session and +/// the crypto-header to stamp into every superblock slot. +struct CreateCrypto { + page_cipher: crate::crypto::PageCipher, + header: crate::superblock::CryptoHeader, +} + +/// Build the session PageCipher for a freshly-created encrypted DB: generate a +/// random DEK + slot-0 salt, derive the KEK from the client key, wrap the DEK +/// into slot 0, and assemble the crypto-header. Returns the live PageCipher and +/// the header to stamp into every superblock slot. +/// +/// The AAD passed to `wrap_dek` is `slot.aad()` — the same bytes that Task 2.4 +/// reconstructs at unwrap time from the persisted slot fields. Keeping the AAD +/// construction in one place (`KeySlot::aad`) ensures wrap and unwrap agree. +fn build_create_cipher(key: &crate::crypto::Key) -> Result { + use crate::crypto::{ + derive_kek, random_array, random_dek, wrap_dek, Argon2Params, KdfId, NONCE_LEN, SALT_LEN, + }; + use crate::superblock::{CryptoHeader, KeySlot, ALGO_XCHACHA20POLY1305, KEY_SLOT_COUNT}; + + let dek = random_dek(); + let salt: [u8; SALT_LEN] = random_array(); + let wrap_nonce: [u8; NONCE_LEN] = random_array(); + + // KDF choice: a Raw key uses HKDF (fast, key-material quality); + // a Passphrase uses Argon2id (memory-hard, brute-force resistant). + let (kdf, params) = match key { + crate::crypto::Key::Raw(_) => (KdfId::Hkdf, Argon2Params::default()), + crate::crypto::Key::Passphrase(_) => (KdfId::Argon2id, Argon2Params::default()), + }; + let kek = derive_kek(key, kdf, &salt, ¶ms)?; + + // Populate slot 0: state=active, KDF metadata, the wrapped DEK. + // slot.aad() is the canonical AAD bytes; it MUST be the same value + // used by Task 2.4 to unwrap — both sides call KeySlot::aad() on + // the populated-but-pre-wrap slot so the bytes are identical. + let mut slot = KeySlot::EMPTY; + slot.state = 1; // active + slot.kdf_id = kdf as u8; + slot.argon2 = params; + slot.salt = salt; + slot.wrap_nonce = wrap_nonce; + let aad = slot.aad(); + let (wrapped, tag) = wrap_dek(&kek, &dek, &wrap_nonce, &aad); + slot.wrapped_dek = wrapped; + slot.wrap_tag = tag; + + let mut slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + slots[0] = slot; + let header = CryptoHeader { algorithm: ALGO_XCHACHA20POLY1305, stride: 8232, slots }; + + Ok(CreateCrypto { page_cipher: crate::crypto::PageCipher::new(dek), header }) +} diff --git a/src/transaction/tests.rs b/src/transaction/tests.rs index 5d41e26..fa554ca 100644 --- a/src/transaction/tests.rs +++ b/src/transaction/tests.rs @@ -23,7 +23,7 @@ fn fresh_manager() -> TransactionManager { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut tm = TransactionManager::create_new(cache, 2).unwrap(); + let mut tm = TransactionManager::create_new(cache, 2, None).unwrap(); // Commit once so there's a real baseline to read/write against. tm.begin().unwrap(); tm.commit().unwrap(); @@ -261,7 +261,7 @@ fn fatal_error_outside_commit_also_poisons() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut tm = TransactionManager::create_new(cache, 2).unwrap(); + let mut tm = TransactionManager::create_new(cache, 2, None).unwrap(); tm.begin().unwrap(); h = tm.allocate(b"durable").unwrap(); tm.commit().unwrap(); @@ -1270,7 +1270,7 @@ fn commit_does_not_poison_when_cache_is_at_strict_cap() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut tm = TransactionManager::create_new(cache, 2).unwrap(); + let mut tm = TransactionManager::create_new(cache, 2, None).unwrap(); tm.begin().unwrap(); tm.commit().unwrap(); @@ -1532,7 +1532,7 @@ fn delete_membership_failure_survives_reopen_consistently() { crate::SpillwayLocation::InMemory, ); if create { - TransactionManager::create_new(cache, 2).unwrap() + TransactionManager::create_new(cache, 2, None).unwrap() } else { TransactionManager::open_existing(cache).unwrap() } @@ -1810,7 +1810,7 @@ fn allocate_membership_failure_survives_reopen_consistently() { crate::SpillwayLocation::InMemory, ); if create { - TransactionManager::create_new(cache, 2).unwrap() + TransactionManager::create_new(cache, 2, None).unwrap() } else { TransactionManager::open_existing(cache).unwrap() } @@ -2051,7 +2051,7 @@ fn format_version_gate_is_major_only() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let _ = TransactionManager::create_new(cache, 2).unwrap(); + let _ = TransactionManager::create_new(cache, 2, None).unwrap(); // drop() releases the flock so the test can read+write the // file directly below. } @@ -2137,7 +2137,7 @@ fn file_minor_newer_than_binary_is_forced_read_only() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let _ = TransactionManager::create_new(cache, 2).unwrap(); + let _ = TransactionManager::create_new(cache, 2, None).unwrap(); } // Patch every slot to (current MAJOR, MINOR + 1) and re-stamp checksums. @@ -2300,7 +2300,7 @@ fn reopen_preserves_committed_data() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut txm = TransactionManager::create_new(cache, 2).unwrap(); + let mut txm = TransactionManager::create_new(cache, 2, None).unwrap(); txm.begin().unwrap(); handle = txm.allocate(b"persistent").unwrap(); txm.commit().unwrap(); diff --git a/tests/encryption_create.rs b/tests/encryption_create.rs new file mode 100644 index 0000000..33a2806 --- /dev/null +++ b/tests/encryption_create.rs @@ -0,0 +1,253 @@ +// encryption_create.rs — Integration tests for Task 2.3: create_new with a key. +// +// Scope: verify the CREATE ARTIFACT (the serialised page 0) without exercising +// the open path (the MAJOR=2 gate is Task 2.4). Concretely: +// - MAJOR is 2 in the on-disk superblock +// - slot 0 is populated (state=active) +// - sensitive fields (named_roots names) are NOT in cleartext +// - the wrapped DEK round-trips: unwrap_dek under the same key recovers +// a valid DEK (smoke-tests the wrap/AAD path without going through open) +// +// All file-backed tests use a tempfile that is deleted on drop. + +use chisel::{ + derive_kek, unwrap_dek, CryptoHeader, KdfId, Key, KeySlot, + Options, ALGO_XCHACHA20POLY1305, CRYPTO_HEADER_OFFSET, KEY_SLOT_COUNT, KEY_SLOT_SIZE, + PAGE_SIZE, +}; +use chisel::{format_major, FORMAT_MAJOR_VERSION_ENCRYPTED}; +use std::fs; +use std::io::Read as _; +use zeroize::Zeroizing; + +// ── helper ──────────────────────────────────────────────────────────────────── + +struct TempDb(std::path::PathBuf); + +impl TempDb { + fn new(stem: &str) -> Self { + let p = std::env::temp_dir() + .join(format!("chisel_enc_test_{}_{}.db", stem, std::process::id())); + let _ = fs::remove_file(&p); + TempDb(p) + } + + fn path(&self) -> &std::path::Path { + &self.0 + } + + /// Read page 0 (the first superblock slot) as raw bytes. + fn read_page0(&self) -> [u8; PAGE_SIZE] { + let mut f = fs::File::open(&self.0).unwrap(); + let mut buf = [0u8; PAGE_SIZE]; + f.read_exact(&mut buf).unwrap(); + buf + } +} + +impl Drop for TempDb { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +/// Creating an encrypted DB with a raw key stamps MAJOR=2 in the on-disk +/// superblock (bytes 4..8 hold format_version as little-endian u32; +/// upper 16 bits = MAJOR). +#[test] +fn create_encrypted_db_stamps_major_2() { + let tmp = TempDb::new("major2_raw"); + let key = Key::Raw(Zeroizing::new(vec![0xAB_u8; 32])); + let db = chisel::Chisel::open( + tmp.path(), + Options::default().with_encryption_key(key), + ) + .expect("create encrypted db"); + drop(db); + + let page0 = tmp.read_page0(); + let fv = u32::from_le_bytes(page0[4..8].try_into().unwrap()); + assert_eq!( + format_major(fv), + FORMAT_MAJOR_VERSION_ENCRYPTED, + "format_version MAJOR must be 2 for encrypted DB; got {fv:#010x}" + ); +} + +/// Creating with a passphrase also stamps MAJOR=2. +#[test] +fn create_encrypted_db_passphrase_stamps_major_2() { + let tmp = TempDb::new("major2_pass"); + let key = Key::Passphrase(Zeroizing::new("hunter2".to_string())); + let db = chisel::Chisel::open( + tmp.path(), + Options::default().with_encryption_key(key), + ) + .expect("create encrypted db passphrase"); + drop(db); + + let page0 = tmp.read_page0(); + let fv = u32::from_le_bytes(page0[4..8].try_into().unwrap()); + assert_eq!(format_major(fv), FORMAT_MAJOR_VERSION_ENCRYPTED); +} + +/// Key-slot 0 must be active (state byte = 1) and the algorithm byte must +/// be XChaCha20-Poly1305 (= 1). All other slots must be empty (state = 0). +#[test] +fn create_encrypted_db_populates_slot_0_only() { + let tmp = TempDb::new("slot0"); + let key = Key::Raw(Zeroizing::new(vec![0x77_u8; 32])); + let db = chisel::Chisel::open( + tmp.path(), + Options::default().with_encryption_key(key), + ) + .expect("create"); + drop(db); + + let page0 = tmp.read_page0(); + + // Algorithm byte is the first byte of the crypto-header region. + assert_eq!( + page0[CRYPTO_HEADER_OFFSET], + ALGO_XCHACHA20POLY1305, + "algorithm byte must be 1 (XChaCha20-Poly1305)" + ); + + // The slot table immediately follows the 8-byte crypto-header prefix + // (1 byte algo + 4 bytes stride + 3 reserved bytes). + let slot_table_offset = CRYPTO_HEADER_OFFSET + 8; + + // Slot 0 state byte must be 1 (active). + assert_eq!(page0[slot_table_offset], 1, "slot 0 state must be active (1)"); + + // Slots 1..KEY_SLOT_COUNT must all be empty (state = 0). + for i in 1..KEY_SLOT_COUNT { + let base = slot_table_offset + i * KEY_SLOT_SIZE; + assert_eq!(page0[base], 0, "slot {i} must be empty"); + } +} + +/// The encrypted superblock must have non-zero bytes in its sealed-body region. +/// +/// `serialize_encrypted` stores the XChaCha20-Poly1305 ciphertext starting at +/// SEALED_BODY_OFFSET (= CRYPTO_HEADER_OFFSET + CRYPTO_HEADER_SIZE = 1356). +/// That region holds: nonce (24 B) || tag (16 B) || ct_len (2 B) || ciphertext. +/// Checking that the nonce+tag+ciphertext region is non-zero confirms the body +/// was actually sealed (not skipped/left zero). +/// +/// Bytes 52..308 (plaintext named_roots) ARE intentionally zeroed by +/// serialize_encrypted — they are hidden inside the sealed body. We do NOT +/// check those here. +#[test] +fn create_encrypted_db_sealed_body_is_present() { + // SEALED_BODY_OFFSET = CRYPTO_HEADER_OFFSET(324) + CRYPTO_HEADER_SIZE(8 + 8*128) + // = 324 + 1032 = 1356. Sample the nonce field (first 24 bytes of sealed region). + const SEALED_BODY_OFFSET: usize = 1356; + + let tmp = TempDb::new("cleartext_check"); + let key = Key::Raw(Zeroizing::new(vec![0xCC_u8; 32])); + let db = chisel::Chisel::open( + tmp.path(), + Options::default().with_encryption_key(key), + ) + .expect("create"); + drop(db); + + let page0 = tmp.read_page0(); + + // The nonce is 24 random bytes written by serialize_encrypted. They should + // be non-zero (with overwhelming probability — a 192-bit all-zero nonce + // has probability 2^-192 ≈ 10^-58). + let nonce_region = &page0[SEALED_BODY_OFFSET..SEALED_BODY_OFFSET + 24]; + assert!( + nonce_region.iter().any(|&b| b != 0), + "nonce region at SEALED_BODY_OFFSET is all-zero — body was not sealed" + ); +} + +/// Smoke-test the wrap/unwrap path: read the slot-0 salt, nonce, wrapped DEK, +/// and tag from the on-disk superblock, reconstruct the AAD, re-derive the KEK +/// from the same raw key, and verify `unwrap_dek` succeeds. +/// +/// This exercises the wrap→unwrap round-trip without going through the open +/// path (which Task 2.4 implements). +#[test] +fn slot0_dek_unwraps_with_correct_key() { + let tmp = TempDb::new("unwrap"); + let key = Key::Raw(Zeroizing::new(vec![0x5A_u8; 32])); + let db = chisel::Chisel::open( + tmp.path(), + Options::default().with_encryption_key(key.clone()), + ) + .expect("create"); + drop(db); + + let page0 = tmp.read_page0(); + + // Deserialize the crypto-header to get slot 0. + let header = CryptoHeader::deserialize(&page0) + .expect("page 0 must have a crypto-header for an encrypted DB"); + assert_eq!(header.algorithm, ALGO_XCHACHA20POLY1305); + + let slot = &header.slots[0]; + assert!(slot.is_active(), "slot 0 must be active"); + assert_eq!(slot.kdf_id, KdfId::Hkdf as u8, "raw key → HKDF"); + + // Re-derive KEK using the same raw key + the stored salt + params. + let kek = derive_kek(&key, KdfId::Hkdf, &slot.salt, &slot.argon2) + .expect("derive_kek must succeed with the correct key"); + + // Reconstruct the AAD exactly as build_create_cipher did: populate the + // slot fields (state, kdf_id, argon2, salt, wrap_nonce) then call aad(). + // The wrapped_dek and wrap_tag fields are NOT part of the AAD. + let mut aad_slot = KeySlot::EMPTY; + aad_slot.state = slot.state; + aad_slot.kdf_id = slot.kdf_id; + aad_slot.argon2 = slot.argon2; + aad_slot.salt = slot.salt; + aad_slot.wrap_nonce = slot.wrap_nonce; + let aad = aad_slot.aad(); + + // Unwrap must succeed. + let dek = unwrap_dek(&kek, &slot.wrapped_dek, &slot.wrap_tag, &slot.wrap_nonce, &aad) + .expect("unwrap_dek must succeed with the correct key and AAD"); + + // The DEK must be non-trivial (not all zeros). + assert_ne!(dek.as_bytes(), &[0u8; 32], "unwrapped DEK must not be all zeros"); +} + +/// Wrong key must fail unwrap (AEAD authentication failure). +#[test] +fn slot0_dek_unwrap_fails_with_wrong_key() { + let tmp = TempDb::new("wrong_key"); + let key = Key::Raw(Zeroizing::new(vec![0x5A_u8; 32])); + let db = chisel::Chisel::open( + tmp.path(), + Options::default().with_encryption_key(key), + ) + .expect("create"); + drop(db); + + let page0 = tmp.read_page0(); + let header = CryptoHeader::deserialize(&page0).expect("crypto header"); + let slot = &header.slots[0]; + + let wrong_key = Key::Raw(Zeroizing::new(vec![0xFF_u8; 32])); + let kek = + derive_kek(&wrong_key, KdfId::Hkdf, &slot.salt, &slot.argon2).expect("derive_kek"); + + let mut aad_slot = KeySlot::EMPTY; + aad_slot.state = slot.state; + aad_slot.kdf_id = slot.kdf_id; + aad_slot.argon2 = slot.argon2; + aad_slot.salt = slot.salt; + aad_slot.wrap_nonce = slot.wrap_nonce; + let aad = aad_slot.aad(); + + assert!( + unwrap_dek(&kek, &slot.wrapped_dek, &slot.wrap_tag, &slot.wrap_nonce, &aad).is_err(), + "wrong key must fail DEK unwrap" + ); +} From 9a2a663b0adbe39ebaa6737561f7c84a31145c66 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 00:40:01 -0700 Subject: [PATCH 16/42] test(superblock): assert encrypted named_roots name is absent from cleartext --- src/superblock/mod.rs | 60 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/superblock/mod.rs b/src/superblock/mod.rs index 204abdc..d954eb4 100644 --- a/src/superblock/mod.rs +++ b/src/superblock/mod.rs @@ -1067,6 +1067,66 @@ mod tests { assert!(back.decrypt_body(&cipher, &buf).is_err()); } + /// Security property: a recognizable named-root name must NOT appear in + /// cleartext anywhere in the serialized page for an encrypted superblock. + /// + /// This is the core anti-leak assertion for Task 2.3. The name bytes are + /// stored only inside the DEK-sealed body (ciphertext), so they must be + /// invisible in the raw page. The test also verifies the sentinel IS + /// recovered after `decrypt_body`, proving it was sealed rather than dropped. + #[test] + fn encrypted_named_root_name_absent_from_cleartext() { + use crate::crypto::{random_dek, PageCipher}; + + // Sentinel: exactly NAMED_ROOT_NAME_LEN (24) bytes, recognizable prefix. + // "secret-LEAKCHECK" = 16 ASCII bytes, padded with zeros to fill the slot. + let mut sentinel = [0u8; NAMED_ROOT_NAME_LEN]; + sentinel[..16].copy_from_slice(b"secret-LEAKCHECK"); + + let cipher = PageCipher::new(random_dek()); + let mut header_slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; + header_slots[0].state = 1; + let header = CryptoHeader { + algorithm: ALGO_XCHACHA20POLY1305, + stride: 8232, + slots: header_slots, + }; + + let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT); + sb.named_roots[0].name = sentinel; + sb.named_roots[0].handle = 0xDEAD_BEEF_CAFE_0001; + sb.encryption = Some(header); + + let buf = sb.serialize_encrypted(&cipher); + + // 1. The named_roots region (52..308) must be zero in cleartext. + assert_eq!( + &buf[52..308], + &[0u8; 256][..], + "named_roots region (52..308) is not zeroed in encrypted superblock" + ); + + // 2. The sentinel bytes must NOT appear as a contiguous subsequence + // anywhere in the full page (including the sealed-body region). + // The 16-byte scan window is long enough to be collision-resistant + // against random ciphertext (prob ≈ 2^-128). + let needle = &sentinel[..16]; + assert!( + !buf.windows(needle.len()).any(|w| w == needle), + "sentinel name appears in cleartext page — named_root name leaked" + ); + + // 3. Round-trip: decrypt_body must recover the sentinel, proving it was + // sealed (not silently dropped). + let mut back = Superblock::deserialize(&buf).expect("encrypted sb must deserialize"); + back.decrypt_body(&cipher, &buf).expect("correct DEK must open body"); + assert_eq!( + back.named_roots[0].name, sentinel, + "named_root name not recovered after decrypt_body" + ); + assert_eq!(back.named_roots[0].handle, 0xDEAD_BEEF_CAFE_0001); + } + /// Plaintext DBs must serialize byte-identically to the pre-encryption /// implementation (regression guard: `encryption: None` path is unchanged). #[test] From b0cfd06f61acc895f682f14ec23f19ea0eefa7a1 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 09:07:17 -0700 Subject: [PATCH 17/42] =?UTF-8?q?feat(engine):=20open=20encrypted=20databa?= =?UTF-8?q?se=20=E2=80=94=20unwrap=20DEK=20from=20key-slot,=20decrypt=20bo?= =?UTF-8?q?dy,=20gate=20MAJOR=3D2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib.rs | 2 +- src/superblock/crypto_header.rs | 4 - src/superblock/mod.rs | 6 - src/transaction/commit.rs | 25 ++++- src/transaction/lifecycle.rs | 2 + src/transaction/mod.rs | 11 +- src/transaction/recovery.rs | 113 +++++++++++++++++-- src/transaction/tests.rs | 14 +-- tests/encryption_open.rs | 189 ++++++++++++++++++++++++++++++++ 9 files changed, 330 insertions(+), 36 deletions(-) create mode 100644 tests/encryption_open.rs diff --git a/src/lib.rs b/src/lib.rs index 01206c5..74aea56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -361,7 +361,7 @@ impl Chisel { let txm = if file_exists { // Existing database: N is discovered from the on-disk // superblock. options.superblock_count is ignored here. - TransactionManager::open_existing(cache)? + TransactionManager::open_existing(cache, options.encryption_key.clone())? } else { TransactionManager::create_new( cache, diff --git a/src/superblock/crypto_header.rs b/src/superblock/crypto_header.rs index 2ecbfa4..0e36db5 100644 --- a/src/superblock/crypto_header.rs +++ b/src/superblock/crypto_header.rs @@ -5,10 +5,6 @@ // the reserved region stays zeroed and `deserialize` returns None (algorithm 0). // // Consumed by superblock/mod.rs (serialize_encrypted, deserialize, decrypt_body). -// ponytail: items here are called from serialize_encrypted / decrypt_body which -// themselves are dead-code-warned at the superblock level pending Phase 2.4 -// wiring; suppress until that caller lands. -#![allow(dead_code)] // // On-disk layout (all inside the superblock's reserved region, after freemap_depth): // 324..325 algorithm (u8; 1 = XChaCha20-Poly1305, 0 = none/plaintext) diff --git a/src/superblock/mod.rs b/src/superblock/mod.rs index d954eb4..c2ae157 100644 --- a/src/superblock/mod.rs +++ b/src/superblock/mod.rs @@ -322,10 +322,6 @@ impl Superblock { buf } - // ponytail: methods below are called from serialize_encrypted/decrypt_body - // which in turn are called from tests and will be wired to the commit/open - // path in Task 2.4. Suppress dead_code until that caller lands. - #[allow(dead_code)] /// Build the AAD that binds the sealed body and each key-slot's DEK wrap to /// this superblock's plaintext identity. The four bootstrap fields that stay /// cleartext in both encrypted and plaintext DBs are included; this prevents @@ -387,7 +383,6 @@ impl Superblock { /// leaks (named_roots at 52..308, root/page-id scalars at 16..52, etc.). /// /// Panics if `self.encryption` is `None` — only call for encrypted DBs. - #[allow(dead_code)] pub fn serialize_encrypted(&self, cipher: &crate::crypto::PageCipher) -> [u8; PAGE_SIZE] { let header = self .encryption @@ -423,7 +418,6 @@ impl Superblock { /// already called `deserialize` (which fills bootstrap fields and the /// crypto-header from cleartext) and obtained the matching DEK. Returns /// `CryptoError::Auth` if the DEK or AAD is wrong, or the blob is tampered. - #[allow(dead_code)] pub fn decrypt_body( &mut self, cipher: &crate::crypto::PageCipher, diff --git a/src/transaction/commit.rs b/src/transaction/commit.rs index b30cc8e..827a778 100644 --- a/src/transaction/commit.rs +++ b/src/transaction/commit.rs @@ -23,6 +23,12 @@ pub(super) struct CommitCtx<'a> { pub txn_counter: &'a mut u64, pub active_txn: &'a mut bool, pub superblock_count: u32, + /// Cipher for an encrypted database; `None` for plaintext. Needed here so the + /// superblock body (sensitive fields) is sealed before being written to disk. + pub cipher: Option<&'a crate::crypto::PageCipher>, + /// The key-slot table stamped into the on-disk superblock. Carried through + /// the session unchanged; None for plaintext DBs. + pub crypto_header: Option<&'a crate::superblock::CryptoHeader>, } pub(super) fn run_commit(ctx: &mut CommitCtx<'_>) -> Result<()> { @@ -115,7 +121,12 @@ pub(super) fn run_commit(ctx: &mut CommitCtx<'_>) -> Result<()> { let total_pages = cache.file_page_count()?; let sb = Superblock { magic: page::MAGIC, - format_version: page::FORMAT_VERSION, + // Encrypted DBs use MAJOR=2 so an old binary rejects them. + format_version: if ctx.crypto_header.is_some() { + page::format_version_encrypted() + } else { + page::FORMAT_VERSION + }, txn_counter: *ctx.txn_counter, root_handle_table_page: ctx.current_roots.handle_table_page, root_freemap_page: ctx.current_roots.freemap_page, @@ -131,12 +142,14 @@ pub(super) fn run_commit(ctx: &mut CommitCtx<'_>) -> Result<()> { // Freemap tree depth, paired with root_freemap_page. 0 = today's // single-leaf format; grows as the tree deepens. freemap_depth: ctx.current_roots.freemap_depth, - // Encryption header: None for plaintext DBs (the current path). - // Task 2.4 will populate this from the open-time session context - // for encrypted DBs and switch to serialize_encrypted. - encryption: None, + encryption: ctx.crypto_header.copied(), + }; + // For encrypted DBs, seal the body (sensitive fields) under the session DEK. + // The crypto-header (key-slot table) is written verbatim in cleartext. + let buf = match (ctx.cipher, ctx.crypto_header) { + (Some(cipher), Some(_)) => sb.serialize_encrypted(cipher), + _ => sb.serialize(), }; - let buf = sb.serialize(); // Step 3: Write to the INACTIVE slot. For N superblock slots, // the slot is `txn_counter % N` — a round-robin that always // targets the stalest slot. With N=2 this is the parity diff --git a/src/transaction/lifecycle.rs b/src/transaction/lifecycle.rs index 747b28e..ebd9ea6 100644 --- a/src/transaction/lifecycle.rs +++ b/src/transaction/lifecycle.rs @@ -216,6 +216,8 @@ impl TransactionManager { txn_counter: &mut self.txn_counter, active_txn: &mut self.active_txn, superblock_count: self.superblock_count, + cipher: self.cipher.as_ref(), + crypto_header: self.crypto_header.as_ref(), }) } diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index 8e8d438..e77166e 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -218,10 +218,15 @@ pub struct TransactionManager { /// Per-session page cipher for an encrypted database. `None` for plaintext. /// Holds the unwrapped DEK (zeroizing) for the life of the manager; reaches /// the PageCache in Phase 3 for per-page seal/open. Set on the create path - /// (fresh DEK) and on the open path when Task 2.4 lands (DEK unwrapped from - /// a key-slot). The DEK inside PageCipher is zeroizing and is cleared on drop. - #[allow(dead_code)] // used by create path; Phase 3 wires it to page I/O + /// (fresh DEK) and on the open path (DEK unwrapped from a key-slot). The DEK + /// inside PageCipher is zeroizing and is cleared on drop. + #[allow(dead_code)] // Phase 3 wires this to page I/O; commit path uses it via CommitCtx cipher: Option, + /// The crypto-header (algorithm id + key-slot table) for an encrypted database. + /// Written verbatim into every committed superblock. `None` for plaintext DBs. + /// The key-slot contents never change after create or open: the slots hold the + /// DEK wrapped under KEKs from each user key and are opaque to the commit path. + crypto_header: Option, // Test-only fault injection consolidated off the production type (review // 2026-06-22 SMELL #4): the four BUG#2 atomic-staging arming flags live in diff --git a/src/transaction/recovery.rs b/src/transaction/recovery.rs index cdace86..62423fc 100644 --- a/src/transaction/recovery.rs +++ b/src/transaction/recovery.rs @@ -44,7 +44,7 @@ impl TransactionManager { // Build the per-session cipher up front for an encrypted DB: a fresh // random DEK is sealed into slot 0 under a KEK derived from `key`. // For plaintext DBs this is None and the slot-write loop uses serialize(). - let cipher = match key { + let create_crypto = match key { None => None, Some(k) => Some(build_create_cipher(&k)?), }; @@ -60,13 +60,13 @@ impl TransactionManager { // `select()` at open time will pick slot 0 (highest counter). // After the first user commit, slot 0 holds the newest data // and the rest remain as "rollback fallbacks". - let mut sb = match &cipher { + let mut sb = match &create_crypto { None => Superblock::new_empty(superblock_count), Some(cc) => Superblock::new_empty_encrypted(superblock_count, cc.header), }; for i in 0..superblock_count { sb.txn_counter = (superblock_count - 1 - i) as u64; - let buf = match &cipher { + let buf = match &create_crypto { None => sb.serialize(), Some(cc) => sb.serialize_encrypted(&cc.page_cipher), }; @@ -93,6 +93,12 @@ impl TransactionManager { membership_index_page: PAGE_ID_NONE, }; + // Split the CreateCrypto struct into its session parts before consuming. + let (session_cipher, session_header) = match create_crypto { + None => (None, None), + Some(cc) => (Some(cc.page_cipher), Some(cc.header)), + }; + Ok(TransactionManager { cache: RefCell::new(cache), committed_roots: roots.clone(), @@ -111,7 +117,8 @@ impl TransactionManager { // A fresh database has no data pages and no live slots yet. packer: packing::SlotPacker::new(), poisoned: Cell::new(false), - cipher: cipher.map(|cc| cc.page_cipher), + cipher: session_cipher, + crypto_header: session_header, #[cfg(test)] fault: fault::FaultInjector::default(), }) @@ -152,7 +159,10 @@ impl TransactionManager { /// If no valid superblock is found in the first MAX_SUPERBLOCKS /// pages, we return `CorruptSuperblock`. This bounds the probe /// cost in the pathological case where every candidate is torn. - pub fn open_existing(mut cache: PageCache) -> Result { + pub fn open_existing( + mut cache: PageCache, + key: Option, + ) -> Result { // Step 1: read up to MAX_SUPERBLOCKS pages as candidates. let mut candidates: Vec<[u8; PAGE_SIZE]> = Vec::new(); for i in 0..MAX_SUPERBLOCKS as u64 { @@ -169,10 +179,42 @@ impl TransactionManager { // Step 2 + 3: pick the winner via select(). select() uses // deserialize, which validates checksum and magic — data // pages in the candidate list (if any) are filtered out. - let sb = Superblock::select(&candidates).ok_or_else(|| ChiselError::CorruptSuperblock { + let mut sb = Superblock::select(&candidates).ok_or_else(|| ChiselError::CorruptSuperblock { defects: Superblock::diagnose(&candidates), })?; + // Encryption gate: the winning superblock's crypto-header (already + // parsed by deserialize into sb.encryption) tells us whether the DB + // is encrypted. Mismatches between "DB encrypted?" and "key + // supplied?" are operational open errors, not torn-slot signals. + // + // For an encrypted DB we must decrypt the sealed body (which holds + // total_pages, named_roots, etc.) BEFORE the page-size and + // total_pages checks that read those fields. + let cipher = match (&sb.encryption, &key) { + (None, None) => None, + (Some(_), None) => return Err(ChiselError::NoEncryptionKey), + (None, Some(_)) => return Err(ChiselError::EncryptionNotSupported), + (Some(header), Some(k)) => { + // The winning slot's raw bytes: slot index is + // txn_counter % superblock_count, which is how commit + // selects the write slot, so the highest-counter slot is + // always at this index in the candidates array. + let slot_idx = + (sb.txn_counter % sb.superblock_count as u64) as usize; + let raw = &candidates[slot_idx]; + let dek = unwrap_first_matching_slot(header, k)?; + let cipher = crate::crypto::PageCipher::new(dek); + // decrypt_body fills total_pages, named_roots, etc. + // A tag failure here means corruption, not a wrong key + // (the slot already authenticated the DEK), so map to + // InvalidEncryptionKey rather than poisoning. + sb.decrypt_body(&cipher, raw) + .map_err(|_| ChiselError::InvalidEncryptionKey)?; + Some(cipher) + } + }; + // Format-version gate (see ISSUES.md I15 for the original check, // I29 for the major/minor split). Compare MAJOR only: the packed // u32 layout (upper 16 = major, lower 16 = minor) lets same-major @@ -187,7 +229,17 @@ impl TransactionManager { // compatibility — silently falling back to an older-version // superblock would hand the user a stale snapshot with // mysteriously missing data. - if page::format_major(sb.format_version) != page::FORMAT_MAJOR_VERSION { + // + // Encrypted DBs carry MAJOR=2; plaintext DBs carry MAJOR=1. An old + // binary (FORMAT_MAJOR_VERSION=1) rejects MAJOR=2 as unsupported, + // which is correct. A new binary accepts either, gated on whether + // the encryption header is present. + let expected_major = if sb.encryption.is_some() { + page::FORMAT_MAJOR_VERSION_ENCRYPTED + } else { + page::FORMAT_MAJOR_VERSION + }; + if page::format_major(sb.format_version) != expected_major { return Err(ChiselError::UnsupportedFormatVersion { found: sb.format_version, expected: page::FORMAT_VERSION, @@ -332,8 +384,12 @@ impl TransactionManager { freemap: freemap::FreemapRecycle::new(), packer: packing::SlotPacker::from_committed(committed_live_slots), poisoned: Cell::new(false), - // Task 2.4 fills this from the supplied key + the on-disk slot. - cipher: None, + cipher, + // The CryptoHeader (key-slot table + algorithm) is preserved from the + // winning superblock so commit can write it back verbatim. It never + // changes between opens: slots are only mutated by key-rotation (a + // future operation). None for plaintext DBs. + crypto_header: sb.encryption, #[cfg(test)] fault: fault::FaultInjector::default(), }) @@ -394,3 +450,42 @@ fn build_create_cipher(key: &crate::crypto::Key) -> Result { Ok(CreateCrypto { page_cipher: crate::crypto::PageCipher::new(dek), header }) } + +/// Try every ACTIVE key-slot in turn: derive the KEK from `key` + the slot's +/// salt/params, then attempt to unwrap the DEK. The first slot whose AEAD tag +/// verifies yields the DEK. If no slot matches, the caller's key is wrong. +/// +/// Trying every active slot (rather than a slot-index hint) is what makes +/// multi-key support possible: a DB may have the same DEK wrapped under +/// several KEKs (one per trusted key), and the caller's key matches exactly +/// one of them. +/// +/// The AAD passed to `unwrap_dek` is `slot.aad()` — the identical bytes +/// that `build_create_cipher` used at wrap time. Both sides call +/// `KeySlot::aad()` on the fully populated (but pre-wrap) slot, so the +/// bytes agree even if the slot layout changes in a future format version. +fn unwrap_first_matching_slot( + header: &crate::superblock::CryptoHeader, + key: &crate::crypto::Key, +) -> Result { + use crate::crypto::{derive_kek, unwrap_dek, KdfId}; + + for slot in header.slots.iter().filter(|s| s.is_active()) { + let kdf = match slot.kdf_id { + 1 => KdfId::Hkdf, + 2 => KdfId::Argon2id, + _ => continue, // unknown KDF id: skip, treat as non-matching + }; + let kek = match derive_kek(key, kdf, &slot.salt, &slot.argon2) { + Ok(k) => k, + Err(_) => continue, + }; + let aad = slot.aad(); + if let Ok(dek) = + unwrap_dek(&kek, &slot.wrapped_dek, &slot.wrap_tag, &slot.wrap_nonce, &aad) + { + return Ok(dek); + } + } + Err(ChiselError::InvalidEncryptionKey) +} diff --git a/src/transaction/tests.rs b/src/transaction/tests.rs index fa554ca..7eb5623 100644 --- a/src/transaction/tests.rs +++ b/src/transaction/tests.rs @@ -277,7 +277,7 @@ fn fatal_error_outside_commit_also_poisons() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let tm = TransactionManager::open_existing(cache).unwrap(); + let tm = TransactionManager::open_existing(cache, None).unwrap(); tm.cache.borrow().io().arm_fault(Fault::FailReadPage(pid)); let result = tm.read(h); assert!( @@ -1534,7 +1534,7 @@ fn delete_membership_failure_survives_reopen_consistently() { if create { TransactionManager::create_new(cache, 2, None).unwrap() } else { - TransactionManager::open_existing(cache).unwrap() + TransactionManager::open_existing(cache, None).unwrap() } }; @@ -1812,7 +1812,7 @@ fn allocate_membership_failure_survives_reopen_consistently() { if create { TransactionManager::create_new(cache, 2, None).unwrap() } else { - TransactionManager::open_existing(cache).unwrap() + TransactionManager::open_existing(cache, None).unwrap() } }; @@ -2089,7 +2089,7 @@ fn format_version_gate_is_major_only() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let tm = TransactionManager::open_existing(cache); + let tm = TransactionManager::open_existing(cache, None); assert!( tm.is_ok(), "same-major / different-minor file should open cleanly; got {:?}", @@ -2110,7 +2110,7 @@ fn format_version_gate_is_major_only() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - match TransactionManager::open_existing(cache) { + match TransactionManager::open_existing(cache, None) { Err(ChiselError::UnsupportedFormatVersion { .. }) => {} Err(e) => panic!("expected UnsupportedFormatVersion, got {e:?}"), Ok(_) => panic!("expected UnsupportedFormatVersion, got Ok"), @@ -2162,7 +2162,7 @@ fn file_minor_newer_than_binary_is_forced_read_only() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut tm = TransactionManager::open_existing(cache) + let mut tm = TransactionManager::open_existing(cache, None) .expect("a newer-minor file must still OPEN (reads are additive-safe)"); assert!( matches!(tm.begin(), Err(ChiselError::ReadOnlyMode)), @@ -2314,7 +2314,7 @@ fn reopen_preserves_committed_data() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let txm = TransactionManager::open_existing(cache).unwrap(); + let txm = TransactionManager::open_existing(cache, None).unwrap(); let data = txm.read(handle).unwrap(); assert_eq!(data, b"persistent"); } diff --git a/tests/encryption_open.rs b/tests/encryption_open.rs new file mode 100644 index 0000000..3404c20 --- /dev/null +++ b/tests/encryption_open.rs @@ -0,0 +1,189 @@ +// tests/encryption_open.rs — integration tests for the open_existing-with-key path. +// +// Exercises: correct-key round-trip, wrong-key operational error, missing-key +// error, spurious-key-on-plaintext error, and plaintext-DB regression. + +use chisel::{Chisel, Key, Options}; +use zeroize::Zeroizing; + +fn raw_key(b: u8) -> Key { + Key::Raw(Zeroizing::new(vec![b; 32])) +} + +/// Create an encrypted DB, insert a value, close, reopen with the same key, +/// verify the value is still readable. +#[test] +fn round_trip_open_with_correct_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("e.chisel"); + let handle; + { + let mut db = Chisel::open( + &path, + Options::default().with_encryption_key(raw_key(0x11)), + ) + .unwrap(); + db.begin().unwrap(); + handle = db.allocate(b"hello world").unwrap(); + db.commit().unwrap(); + } + // Reopen with the same key: data must come back. + { + let db = Chisel::open( + &path, + Options::default() + .with_encryption_key(raw_key(0x11)) + .create_if_missing(false), + ) + .unwrap(); + assert_eq!(db.read(handle).unwrap(), b"hello world"); + } +} + +/// Wrong key must fail cleanly — not panic or poison the manager — and a +/// subsequent correct-key open must succeed (retryable error). +#[test] +fn wrong_key_is_operational_error_not_panic() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("e.chisel"); + { + let mut db = Chisel::open( + &path, + Options::default().with_encryption_key(raw_key(0x11)), + ) + .unwrap(); + db.begin().unwrap(); + db.commit().unwrap(); + } + // Wrong key: must return an error. + let err = Chisel::open( + &path, + Options::default() + .with_encryption_key(raw_key(0x22)) + .create_if_missing(false), + ); + assert!(err.is_err(), "wrong key must fail to open"); + + // Correct key after a failed attempt: must succeed (wrong key is NOT fatal/poison). + let ok = Chisel::open( + &path, + Options::default() + .with_encryption_key(raw_key(0x11)) + .create_if_missing(false), + ); + assert!(ok.is_ok(), "correct key must succeed after a wrong-key attempt"); +} + +/// Opening an encrypted DB without supplying a key must error. +#[test] +fn missing_key_on_encrypted_db_errors() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("e.chisel"); + { + let mut db = Chisel::open( + &path, + Options::default().with_encryption_key(raw_key(0x11)), + ) + .unwrap(); + db.begin().unwrap(); + db.commit().unwrap(); + } + let err = Chisel::open(&path, Options::default().create_if_missing(false)); + assert!(err.is_err(), "opening an encrypted DB without a key must fail"); +} + +/// Supplying a key to a plaintext DB must error. +#[test] +fn key_supplied_for_plaintext_db_errors() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("p.chisel"); + { + let mut db = Chisel::open(&path, Options::default()).unwrap(); + db.begin().unwrap(); + db.commit().unwrap(); + } + let err = Chisel::open( + &path, + Options::default() + .with_encryption_key(raw_key(0x11)) + .create_if_missing(false), + ); + assert!(err.is_err(), "supplying a key to a plaintext DB must fail"); +} + +/// Plaintext DB created and reopened without a key must still work (regression +/// guard: the version gate must not accidentally break MAJOR=1 DBs). +#[test] +fn plaintext_db_round_trips_without_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("plain.chisel"); + let handle; + { + let mut db = Chisel::open(&path, Options::default()).unwrap(); + db.begin().unwrap(); + handle = db.allocate(b"plain text").unwrap(); + db.commit().unwrap(); + } + { + let db = Chisel::open(&path, Options::default().create_if_missing(false)).unwrap(); + assert_eq!(db.read(handle).unwrap(), b"plain text"); + } +} + +/// Passphrase-keyed DB round-trips: create with a passphrase, reopen with the +/// same passphrase, data is intact. +#[test] +fn passphrase_key_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("passphrase.chisel"); + let pass = || Key::Passphrase(Zeroizing::new("correct horse battery staple".to_string())); + let handle; + { + let mut db = + Chisel::open(&path, Options::default().with_encryption_key(pass())).unwrap(); + db.begin().unwrap(); + handle = db.allocate(b"secret").unwrap(); + db.commit().unwrap(); + } + { + let db = Chisel::open( + &path, + Options::default() + .with_encryption_key(pass()) + .create_if_missing(false), + ) + .unwrap(); + assert_eq!(db.read(handle).unwrap(), b"secret"); + } +} + +/// Named root written under an encrypted DB must round-trip through open. +#[test] +fn named_root_round_trips_through_encrypted_open() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("named.chisel"); + let handle; + { + let mut db = Chisel::open( + &path, + Options::default().with_encryption_key(raw_key(0xAB)), + ) + .unwrap(); + db.begin().unwrap(); + handle = db.allocate(b"payload").unwrap(); + db.set_root_name("myroot", handle).unwrap(); + db.commit().unwrap(); + } + { + let db = Chisel::open( + &path, + Options::default() + .with_encryption_key(raw_key(0xAB)) + .create_if_missing(false), + ) + .unwrap(); + let h = db.get_root_name("myroot").unwrap(); + assert!(h.is_some(), "named root must survive close+reopen"); + assert_eq!(db.read(h.unwrap()).unwrap(), b"payload"); + } +} From 7810428d0f474e4b0b5d5228d3f64328d28792a3 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 09:19:26 -0700 Subject: [PATCH 18/42] fix(recovery): select winning superblock buffer directly for encrypted-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). --- src/superblock/mod.rs | 33 +++++++++----------------- src/transaction/recovery.rs | 46 ++++++++++++++++++++++++++----------- tests/encryption_open.rs | 34 +++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/src/superblock/mod.rs b/src/superblock/mod.rs index c2ae157..6970949 100644 --- a/src/superblock/mod.rs +++ b/src/superblock/mod.rs @@ -537,31 +537,20 @@ impl Superblock { /// Select the active superblock from a list of candidate slot buffers. /// - /// Correctness of crash recovery rides on this: we deserialize every - /// candidate, discard any whose checksum/magic/count-range validation - /// fails, and pick the survivor with the highest `txn_counter`. - /// Because the commit protocol fsyncs data pages *before* writing the - /// new superblock, the highest-counter valid superblock is guaranteed - /// to reference a fully-durable page set. + /// Deserialize every candidate, discard any whose + /// checksum/magic/count-range validation fails, and return the + /// survivor with the highest `txn_counter`. /// - /// The caller (`TransactionManager::open_existing`) passes up to - /// MAX_SUPERBLOCKS candidate pages without first trying to determine - /// N: non-superblock pages (data / overflow / freemap / handle-table) - /// that happen to land in the probed range will fail the MAGIC check - /// inside `deserialize` and be filtered out harmlessly. This is why - /// the open path reads blindly up to MAX_SUPERBLOCKS rather than - /// trying to look up N first. + /// Used in tests only. Production code (`open_existing`) inlines the + /// same `filter_map` + `max_by_key` so it can keep the winning buffer + /// alongside the deserialized superblock for `decrypt_body`. /// - /// Returns None only when *every* candidate is corrupt — the caller - /// should treat that as `CorruptSuperblock` (fatal). + /// Returns None only when *every* candidate is corrupt. /// - /// Tie-break policy: on a `txn_counter` tie, `max_by_key` returns the - /// FIRST maximum in iteration order — i.e. the slot with the lowest - /// page id. Ties should not arise in normal operation (every - /// successful commit bumps the counter), but they can appear during - /// the `create_new` seeding window before the first user commit and - /// in hand-crafted corruption-repair scenarios. Lowest-slot-wins is - /// deterministic and matches the slot-0-is-primary intuition. + /// Tie-break: `max_by_key` returns the LAST maximum in iteration order + /// (highest page index on a tie). Ties should not arise in normal + /// operation; this is a deterministic fallback for tests. + #[cfg(test)] pub fn select(buffers: &[[u8; PAGE_SIZE]]) -> Option { buffers .iter() diff --git a/src/transaction/recovery.rs b/src/transaction/recovery.rs index 62423fc..f018094 100644 --- a/src/transaction/recovery.rs +++ b/src/transaction/recovery.rs @@ -176,12 +176,27 @@ impl TransactionManager { } } - // Step 2 + 3: pick the winner via select(). select() uses - // deserialize, which validates checksum and magic — data - // pages in the candidate list (if any) are filtered out. - let mut sb = Superblock::select(&candidates).ok_or_else(|| ChiselError::CorruptSuperblock { - defects: Superblock::diagnose(&candidates), - })?; + // Step 2 + 3: pick the winner. We need BOTH the deserialized superblock + // AND the raw buffer it came from (for decrypt_body's AAD reconstruction). + // select() discards the buffer index, so we re-select here as a + // (superblock, &buf) pair. Tie-break is identical to select(): max_by_key + // returns the last maximum, matching the plaintext path's behavior. + // + // IMPORTANT: do NOT reconstruct the buffer index from + // `txn_counter % superblock_count`. That formula matches the commit + // write-slot rule but is INVERTED for the create-seeding rule (slot i + // gets counter N-1-i), so a freshly-created encrypted DB that has never + // been committed would yield slot_idx pointing at the LOSER buffer — + // decrypt_body builds its AAD from the winner's txn_counter but reads + // the sealed body from the wrong buffer, causing an AAD mismatch → + // CryptoError::Auth → the correct key wrongly fails to open. + let (mut sb, raw) = candidates + .iter() + .filter_map(|b| Superblock::deserialize(b).map(|sb| (sb, b))) + .max_by_key(|(sb, _)| sb.txn_counter) + .ok_or_else(|| ChiselError::CorruptSuperblock { + defects: Superblock::diagnose(&candidates), + })?; // Encryption gate: the winning superblock's crypto-header (already // parsed by deserialize into sb.encryption) tells us whether the DB @@ -196,13 +211,8 @@ impl TransactionManager { (Some(_), None) => return Err(ChiselError::NoEncryptionKey), (None, Some(_)) => return Err(ChiselError::EncryptionNotSupported), (Some(header), Some(k)) => { - // The winning slot's raw bytes: slot index is - // txn_counter % superblock_count, which is how commit - // selects the write slot, so the highest-counter slot is - // always at this index in the candidates array. - let slot_idx = - (sb.txn_counter % sb.superblock_count as u64) as usize; - let raw = &candidates[slot_idx]; + // `raw` is provably the buffer the winner was deserialized from — + // correct regardless of create-seed vs commit write-slot ordering. let dek = unwrap_first_matching_slot(header, k)?; let cipher = crate::crypto::PageCipher::new(dek); // decrypt_body fills total_pages, named_roots, etc. @@ -240,9 +250,17 @@ impl TransactionManager { page::FORMAT_MAJOR_VERSION }; if page::format_major(sb.format_version) != expected_major { + // Report the version the caller should expect for THIS kind of DB + // (encrypted = MAJOR 2, plaintext = MAJOR 1). Using the plaintext + // FORMAT_VERSION for an encrypted file would be misleading. + let expected_version = if sb.encryption.is_some() { + page::format_version_encrypted() + } else { + page::FORMAT_VERSION + }; return Err(ChiselError::UnsupportedFormatVersion { found: sb.format_version, - expected: page::FORMAT_VERSION, + expected: expected_version, }); } diff --git a/tests/encryption_open.rs b/tests/encryption_open.rs index 3404c20..3c66ae5 100644 --- a/tests/encryption_open.rs +++ b/tests/encryption_open.rs @@ -157,6 +157,40 @@ fn passphrase_key_round_trip() { } } +/// Regression: open an encrypted DB immediately after creation, WITHOUT any +/// user commit. Before the fix, `slot_idx = txn_counter % N = (N-1) % N = N-1` +/// pointed at the loser slot (counter 0) while the winner (counter N-1) is at +/// slot 0, causing an AAD mismatch and a spurious InvalidEncryptionKey. +#[test] +fn open_encrypted_db_with_no_commits_uses_correct_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("fresh.chisel"); + // Create with a named root set at create time (so there's something to verify + // round-trips even without a user commit). + { + let _db = Chisel::open( + &path, + Options::default().with_encryption_key(raw_key(0x42)), + ) + .unwrap(); + // Drop immediately — no begin/commit. This is the exact scenario the + // create-seed inversion bug breaks: the winner slot is at page 0 + // (counter N-1) but txn_counter % N = N-1 != 0 for N=2. + } + // Reopen with the correct key: must not return InvalidEncryptionKey. + let result = Chisel::open( + &path, + Options::default() + .with_encryption_key(raw_key(0x42)) + .create_if_missing(false), + ); + assert!( + result.is_ok(), + "correct key must open a never-committed encrypted DB; got: {:?}", + result.err() + ); +} + /// Named root written under an encrypted DB must round-trip through open. #[test] fn named_root_round_trips_through_encrypted_open() { From be3993ec933a47c437ee49038428cb2afa065c77 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 09:29:48 -0700 Subject: [PATCH 19/42] =?UTF-8?q?test(engine):=20verify=20session=20DEK=20?= =?UTF-8?q?lifetime=20=E2=80=94=20cipher=20Some/None=20structural=20invari?= =?UTF-8?q?ants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two unit tests (encrypted_manager_holds_session_cipher, plaintext_manager_has_no_cipher) confirming that TransactionManager retains Option 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. --- src/transaction/tests.rs | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/transaction/tests.rs b/src/transaction/tests.rs index 7eb5623..dbc38db 100644 --- a/src/transaction/tests.rs +++ b/src/transaction/tests.rs @@ -2468,3 +2468,54 @@ fn commit_write_failure_poisons() { ); assert!(tm.is_poisoned(), "write failure during commit must poison"); } + +// --- Session-cipher structural invariants --- +// +// Phase 3 will wire `cipher` to page I/O; these tests confirm the DEK is +// retained for the full session (Some for encrypted DBs, None for plaintext) +// and that nothing leaks it. Zeroization on drop is automatic: PageCipher +// owns a Dek(Zeroizing<[u8; 32]>), so the key is wiped when the manager is +// dropped — no explicit Drop impl needed. In safe Rust, post-drop memory +// content is not observable, so the tests assert the structural invariant +// (Some vs None) rather than attempting to read freed memory. + +#[test] +fn encrypted_manager_holds_session_cipher() { + // create_new with a key must unwrap a fresh DEK and hold it as + // Some(PageCipher) for Phase 3 page I/O wiring. + let file = NamedTempFile::new().unwrap(); + let io = PageIo::open(file.path(), false).unwrap(); + let cache = PageCache::new( + io, + 1024 * PAGE_SIZE as u64, + 0, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + let key = crate::crypto::Key::Raw(zeroize::Zeroizing::new(vec![0x5Au8; 32])); + let txm = TransactionManager::create_new(cache, 2, Some(key)).unwrap(); + assert!( + txm.cipher.is_some(), + "encrypted create must retain a session cipher" + ); +} + +#[test] +fn plaintext_manager_has_no_cipher() { + // create_new without a key must leave cipher as None — no accidental + // encryption on plaintext DBs, and no dead PageCipher in memory. + let file = NamedTempFile::new().unwrap(); + let io = PageIo::open(file.path(), false).unwrap(); + let cache = PageCache::new( + io, + 1024 * PAGE_SIZE as u64, + 0, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + let txm = TransactionManager::create_new(cache, 2, None).unwrap(); + assert!( + txm.cipher.is_none(), + "plaintext create must have no session cipher" + ); +} From 815a9e10da4048831d8559da7ceb226c04d8fb28 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 09:42:47 -0700 Subject: [PATCH 20/42] feat(page_io): stride-aware raw on-disk page units (8232 encrypted, 8192 plaintext) - Backing::Memory switches from Vec<[u8; PAGE_SIZE]> to flat Vec 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. --- src/page_io.rs | 354 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 269 insertions(+), 85 deletions(-) diff --git a/src/page_io.rs b/src/page_io.rs index 8619951..2dc5e7e 100644 --- a/src/page_io.rs +++ b/src/page_io.rs @@ -11,13 +11,24 @@ // - `Backing::File` — the durable path. Owns a `File` handle for its entire // lifetime; the advisory flock is tied to that fd and released on drop. // Two fsyncs per commit; shadow paging guarantees crash consistency. -// - `Backing::Memory` — the ephemeral path. Pages live in a `Vec`; fsync is -// a no-op; no flock is taken. Used for benchmark parity with SQLite -// `:memory:` — see the in-memory-mode spec for the design rationale. +// - `Backing::Memory` — the ephemeral path. Pages live in a flat `Vec` +// addressed by `page_id * stride`; fsync is a no-op; no flock is taken. +// Used for benchmark parity with SQLite `:memory:` — see the in-memory-mode +// spec for the design rationale. +// +// Stride: the on-disk unit size. For plaintext DBs stride == PAGE_SIZE (8192). +// For encrypted DBs stride == ENC_PAGE_SIZE (8232 = 8192 ct + 16 tag + 24 nonce). +// This module is crypto-agnostic: it only moves `stride`-byte blobs; the seal/ +// open transform happens one layer up in PageCache (Task 3.3). Offset math is +// always `page_id * stride`. The engine calls `set_stride(ENC_PAGE_SIZE)` right +// after reading page 0's plaintext bootstrap header on an encrypted open, before +// any other page is read. The default at open is PAGE_SIZE, which is always +// correct for the initial page-0 read regardless of encryption. // // Invariants common to both backings: -// - All reads and writes are page-aligned: offset = page_id * PAGE_SIZE. -// Callers pass logical page IDs; this module never sees byte offsets. +// - All reads and writes are unit-aligned: offset = page_id * stride. The +// stride defaults to PAGE_SIZE and is set to ENC_PAGE_SIZE for encrypted +// DBs after page 0 is read. Callers pass logical page IDs only. // - Platform: macOS and Linux only. `libc::flock` is a BSD/Linux syscall; // Windows is not supported. // - On-disk format is little-endian (see page.rs); this module is @@ -38,10 +49,10 @@ use crate::page::PAGE_SIZE; enum Backing { File { file: File }, // Memory-backed database for benchmarking against SQLite :memory:. - // `pages.len() * PAGE_SIZE` is the on-disk "file size" equivalent; - // allocating a new page is a `Vec::push` of a zero-filled array. + // Flat byte vec addressed by `page_id * stride`; stride-variable so + // encrypted (8232-byte) and plaintext (8192-byte) units both work. // No fsync, no flock, no recovery — see the in-memory-mode spec. - Memory { pages: Vec<[u8; PAGE_SIZE]> }, + Memory { bytes: Vec }, } /// Test-only fault plan armed via `PageIo::arm_fault` (I112). `Copy` so it @@ -72,22 +83,30 @@ pub struct PageIo { // matters: a ReadOnlyMode error is operational — the caller just used // the wrong open mode — while a fatal IoError poisons the manager. read_only: bool, + // On-disk unit size in bytes. PAGE_SIZE for a plaintext DB; + // ENC_PAGE_SIZE (8232 = 8192 ct + 16 tag + 24 nonce) for an encrypted + // DB. This module is crypto-agnostic: it only moves `stride`-byte blobs + // and computes offset = page_id * stride. The engine calls set_stride to + // ENC_PAGE_SIZE immediately after reading page 0's plaintext bootstrap + // header on an encrypted open, before any other page is read. + // page_count() is always reported in stride-units. + stride: usize, // Cumulative fsync count. Cell because `fsync(&self)` takes &self // (single-writer + same-thread reads — see project memory note // `project_chisel_single_client_design`). Read-only opens never fsync, // so this stays at 0 for the read-only lifetime — a useful invariant // when interpreting the counter. fsync_calls: Cell, - // I51 (ISSUES.md, 2026-05-22): cached file length in pages. Eliminates - // the `lseek(End(0))` syscall that every read_page() used to issue - // through `page_count()`. Maintained by: + // I51 (ISSUES.md, 2026-05-22): cached file length in stride-units. + // Eliminates the `lseek(End(0))` syscall that every read_page() used to + // issue through `page_count()`. Maintained by: // - `open()` / `open_in_memory()` — seed from initial file length - // - `write_page()` — extend if page_id+1 > cached value + // - `write_page_unit()` — extend if page_id+1 > cached value // - `set_page_count(n)` — overwrite to n (both grow and shrink) + // - `set_stride(s)` — re-seeds from true file length in new units // Safe under the single-writer flock contract: no other process can // mutate the file behind our back, so the cached value never goes - // stale. Used by both File and Memory backings — for Memory the - // cache mirrors `pages.len()` and the maintenance cost is negligible. + // stale. cached_page_count: Cell, // I112: test-only fault injector. Checked at the top of read_page/ // write_page/fsync; cfg(test) so it is compiled out of production builds @@ -128,10 +147,14 @@ impl PageIo { // without a syscall; the cache is kept in sync by write_page() // and set_page_count(). let initial_len = file.seek(SeekFrom::End(0))?; + // Seed with PAGE_SIZE (the default stride). set_stride() is called + // by the engine immediately after reading the encrypted superblock on + // page 0, at which point it re-seeds the count in ENC_PAGE_SIZE units. let initial_page_count = initial_len / PAGE_SIZE as u64; Ok(PageIo { backing: Backing::File { file }, read_only, + stride: PAGE_SIZE, fsync_calls: Cell::new(0), cached_page_count: Cell::new(initial_page_count), #[cfg(test)] @@ -149,11 +172,12 @@ impl PageIo { /// symmetric with `open` and leaves room for future fallible init. pub fn open_in_memory() -> Result { Ok(PageIo { - backing: Backing::Memory { pages: Vec::new() }, + backing: Backing::Memory { bytes: Vec::new() }, read_only: false, + stride: PAGE_SIZE, fsync_calls: Cell::new(0), - // I51: seeded to 0; write_page() and set_page_count() keep - // it in sync with pages.len() as the Vec grows or shrinks. + // I51: seeded to 0; write_page_unit() and set_page_count() keep + // it in sync with bytes.len() / stride as the Vec grows or shrinks. cached_page_count: Cell::new(0), #[cfg(test)] fault: Cell::new(Fault::None), @@ -178,6 +202,29 @@ impl PageIo { self.read_only = true; } + /// On-disk unit size in bytes (PAGE_SIZE plaintext, ENC_PAGE_SIZE encrypted). + // Dead until Task 3.3 wires the encrypted open path through set_stride. + #[allow(dead_code)] + pub fn stride(&self) -> usize { + self.stride + } + + /// Set the on-disk stride and re-seed the page-count cache against the new + /// unit size. Must be called BEFORE the first unit read on an encrypted DB. + /// The engine does this immediately after reading page 0's plaintext + /// bootstrap header. Re-seeds from the true file length so page_count() + /// is reported in the new stride-units. + // Dead until Task 3.3 wires the encrypted open path through set_stride. + #[allow(dead_code)] + pub fn set_stride(&mut self, stride: usize) { + self.stride = stride; + let len = match &mut self.backing { + Backing::File { file } => file.seek(SeekFrom::End(0)).unwrap_or(0), + Backing::Memory { bytes } => bytes.len() as u64, + }; + self.cached_page_count.set(len / stride as u64); + } + /// Acquire an exclusive advisory lock (flock). Returns LockFailed if /// another process holds it. /// @@ -219,27 +266,21 @@ impl PageIo { Ok(()) } - /// Read a single page by page ID. Returns the page contents by value. + /// Read the raw on-disk unit (`stride` bytes) for `page_id`. /// - /// Returning `[u8; PAGE_SIZE]` by value (not a borrowed slice) is - /// deliberate: `PageCache` will copy the bytes into its own `Box` and - /// run checksum verification there. Keeping this layer buffer-free means - /// callers never accidentally alias the underlying File. + /// Crypto-agnostic: for a plaintext DB the returned blob IS the page + /// image (stride == PAGE_SIZE). For an encrypted DB the blob is the + /// sealed ciphertext‖tag‖nonce, which PageCache hands to PageCipher::open. /// - /// Reading an unallocated page is a bug in the caller, not a - /// recoverable condition. ISSUES.md I16: we surface it as the typed - /// `InvalidPageId` variant rather than the old generic - /// `UnexpectedEof` wrapped as `IoError`, so upstream debugging can - /// distinguish "caller asked for a page that doesn't exist" from - /// "genuine disk I/O failure". + /// Reading an unallocated page is a caller bug. I16: surfaced as the + /// typed `InvalidPageId` rather than a generic `IoError` so that + /// upstream debugging can distinguish "wrong page id" from "disk I/O + /// failure". /// - /// Cost note: post-I51 (2026-05-22) `page_count()` returns a cached - /// value with no syscall, so the bounds check below is effectively - /// free. The cache is seeded at `open()` and maintained by - /// `write_page()` and `set_page_count()`. `&mut self` is still - /// required because the actual page read does `file.seek` + - /// `read_exact` — both side-effectful operations on the File handle. - pub fn read_page(&mut self, page_id: u64) -> Result<[u8; PAGE_SIZE]> { + /// `&mut self` is required because the File branch does `file.seek` + + /// `read_exact` — side-effectful operations on the File handle. The + /// bounds check is effectively free post-I51 (cached page count). + pub fn read_page_unit(&mut self, page_id: u64) -> Result> { let page_count = self.page_count()?; if page_id >= page_count { return Err(ChiselError::InvalidPageId { page_id }); @@ -251,39 +292,46 @@ impl PageIo { "fault-injected read failure", ))); } + let stride = self.stride; match &mut self.backing { Backing::File { file } => { - let offset = page_id * PAGE_SIZE as u64; + let offset = page_id * stride as u64; file.seek(SeekFrom::Start(offset))?; - let mut buf = [0u8; PAGE_SIZE]; + let mut buf = vec![0u8; stride]; file.read_exact(&mut buf)?; Ok(buf) } - // Unchecked index is sound: the `page_id >= page_count` guard - // above already rejected out-of-range ids, and for Memory the - // cached page count is kept identical to `pages.len()` (seeded 0, - // grown by write_page, resized by set_page_count). So a passing - // bounds check guarantees `page_id < pages.len()` here. - Backing::Memory { pages } => Ok(pages[page_id as usize]), + // Sound: the `page_id >= page_count` guard above already rejected + // out-of-range ids, and the cached page count equals + // bytes.len() / stride (maintained by write_page_unit and + // set_page_count). A passing check guarantees the slice exists. + Backing::Memory { bytes } => { + let off = (page_id * stride as u64) as usize; + Ok(bytes[off..off + stride].to_vec()) + } } } - /// Write a single page by page ID. + /// Write a raw on-disk unit (must be exactly `stride` bytes) for `page_id`. /// - /// If `page_id` is beyond the current end of file, the kernel extends - /// the file to cover the write (standard POSIX behavior). This is how - /// new pages allocated by `PageCache::new_page()` physically reach - /// disk — we never explicitly `set_page_count()` when growing during - /// normal operation. + /// Past-EOF writes extend the file; intermediate units are zero-filled + /// (POSIX behavior, matching how new pages reach disk via new_page()). + /// The blob length must equal `stride` — a mismatch is a caller bug + /// returned as an `IoError` rather than silently truncating/padding. /// - /// Note: this write is NOT durable until `fsync()` is called. The - /// shadow-paging commit protocol relies on callers flushing all data - /// pages with fsync BEFORE writing the superblock, and fsyncing AGAIN - /// after the superblock. See transaction.rs::commit. - pub fn write_page(&mut self, page_id: u64, buf: &[u8; PAGE_SIZE]) -> Result<()> { + /// Note: not durable until `fsync()` is called. See the shadow-paging + /// commit protocol in transaction.rs. + pub fn write_page_unit(&mut self, page_id: u64, blob: &[u8]) -> Result<()> { if self.read_only { return Err(ChiselError::ReadOnlyMode); } + if blob.len() != self.stride { + return Err(ChiselError::IoError(std::io::Error::other(format!( + "page unit length {} != stride {}", + blob.len(), + self.stride + )))); + } #[cfg(test)] if self.fault.get() == Fault::FailWritePage(page_id) { self.fault.set(Fault::None); @@ -291,27 +339,28 @@ impl PageIo { "fault-injected write failure", ))); } + let stride = self.stride; match &mut self.backing { Backing::File { file } => { - let offset = page_id * PAGE_SIZE as u64; + let offset = page_id * stride as u64; file.seek(SeekFrom::Start(offset))?; - file.write_all(buf)?; + file.write_all(blob)?; } - Backing::Memory { pages } => { - // Match POSIX: writing past end extends, intermediate pages + Backing::Memory { bytes } => { + // Match POSIX: writing past end extends, intermediate units // are zero-filled. Shadow paging and PageCache::new_page // rely on this growth shape. - let idx = page_id as usize; - if idx >= pages.len() { - pages.resize(idx + 1, [0u8; PAGE_SIZE]); + let off = (page_id * stride as u64) as usize; + let needed = off + stride; + if bytes.len() < needed { + bytes.resize(needed, 0); } - pages[idx] = *buf; + bytes[off..off + stride].copy_from_slice(blob); } } // I51: maintain the page-count cache. Writing past the current // end extends the file (POSIX behavior); intra-cache writes - // don't change it. Use max() so an idempotent write to an - // existing page doesn't decrement the count. + // don't change it. let needed = page_id + 1; if needed > self.cached_page_count.get() { self.cached_page_count.set(needed); @@ -319,6 +368,38 @@ impl PageIo { Ok(()) } + /// Read a single plaintext page by page ID. Returns the page contents + /// by value. + /// + /// Only valid when `stride == PAGE_SIZE` (the plaintext path). Encrypted + /// DBs go through `read_page_unit` directly from PageCache. Returning + /// `[u8; PAGE_SIZE]` by value keeps the layer buffer-free so callers + /// never accidentally alias the underlying File. + pub fn read_page(&mut self, page_id: u64) -> Result<[u8; PAGE_SIZE]> { + debug_assert_eq!( + self.stride, + PAGE_SIZE, + "read_page called on an encrypted stride; use read_page_unit" + ); + let blob = self.read_page_unit(page_id)?; + let mut buf = [0u8; PAGE_SIZE]; + buf.copy_from_slice(&blob); + Ok(buf) + } + + /// Write a single plaintext page by page ID. + /// + /// Only valid when `stride == PAGE_SIZE` (the plaintext path). See + /// `write_page_unit` for the encrypted path. Not durable until `fsync()`. + pub fn write_page(&mut self, page_id: u64, buf: &[u8; PAGE_SIZE]) -> Result<()> { + debug_assert_eq!( + self.stride, + PAGE_SIZE, + "write_page called on an encrypted stride; use write_page_unit" + ); + self.write_page_unit(page_id, buf) + } + /// Flush all writes to durable storage. /// /// `sync_all` translates to `fsync` (Linux) or `fcntl(F_FULLFSYNC)` on @@ -360,7 +441,7 @@ impl PageIo { // No durable storage to flush. The commit protocol still calls // fsync twice per commit; that overhead (two method calls and // two matches) is preserved for benchmark fidelity. - Backing::Memory { .. } => {} + Backing::Memory { bytes: _ } => {} } // Increment AFTER the operation succeeds. A failed fsync is fatal // (fsyncgate — see I1) and the manager will be poisoned, so the @@ -385,43 +466,42 @@ impl PageIo { self.fault.set(f); } - /// Return the number of whole pages in the file. + /// Return the number of whole stride-units (pages) in the file. /// - /// I51 (2026-05-22): returns the cached value. The cache is - /// seeded at `open()` from the initial file length and maintained - /// by `write_page()` (extend on writes past EOF) and - /// `set_page_count()` (resync on truncate/grow). Single-writer - /// flock + private-process ownership of the file makes the cache - /// always coherent — no external mutator can desync it. + /// I51 (2026-05-22): returns the cached value in stride-units. The cache + /// is seeded at `open()` from the initial file length divided by PAGE_SIZE + /// (the default stride), and re-seeded by `set_stride()` when the stride + /// changes. Maintained by `write_page_unit()` (extend on writes past EOF) + /// and `set_page_count()` (resync on truncate/grow). Single-writer flock + + /// private-process ownership of the file makes the cache always coherent. /// - /// I123 (ISSUES.md, 2026-06-21): takes `&self` — the body is a pure `Cell` - /// read (the I51 cached page count), so it never needed `&mut`. Dropping it - /// removes the latent double-borrow risk of forcing a `borrow_mut()` / - /// `io_mut()` on a semantically-read path. Callers holding `&mut` still work - /// (coercion); read-only paths can now use a shared borrow. + /// I123 (ISSUES.md, 2026-06-21): takes `&self` — pure Cell read, no + /// syscall. Dropping `&mut` removes the latent double-borrow risk. pub fn page_count(&self) -> Result { Ok(self.cached_page_count.get()) } - /// Truncate (or extend) the file to exactly `n` pages. + /// Truncate (or extend) the file to exactly `n` stride-units (pages). /// - /// Used by defrag/truncate paths. Shrinking is destructive: pages at - /// id >= n become unreadable immediately. Callers must ensure those - /// pages are not referenced from any committed root before calling. + /// File length is `n * stride` bytes. Used by defrag/truncate paths. + /// Shrinking is destructive: pages at id >= n become unreadable + /// immediately. Callers must ensure those pages are not referenced + /// from any committed root before calling. pub fn set_page_count(&mut self, n: u64) -> Result<()> { if self.read_only { return Err(ChiselError::ReadOnlyMode); } + let stride = self.stride; match &mut self.backing { Backing::File { file } => { - file.set_len(n * PAGE_SIZE as u64)?; + file.set_len(n * stride as u64)?; } - Backing::Memory { pages } => { - pages.resize(n as usize, [0u8; PAGE_SIZE]); + Backing::Memory { bytes } => { + bytes.resize((n * stride as u64) as usize, 0); } } // I51: resync the page-count cache to the authoritative new - // length. Unlike write_page (which only grows), set_page_count + // length. Unlike write_page_unit (which only grows), set_page_count // can shrink too — overwrite the cache rather than max(cache, n). self.cached_page_count.set(n); Ok(()) @@ -836,3 +916,107 @@ mod tests { assert!(io.read_page(0).is_ok(), "one-shot cleared"); } } + +#[cfg(test)] +mod stride_tests { + use super::*; + use crate::crypto::ENC_PAGE_SIZE; + + // Offset math must use the on-disk stride, not PAGE_SIZE. With an + // 8232-byte stride, page 2's blob lives at byte 16464, and page_count + // is reported in stride-units. In-memory backing so the test is + // filesystem-free. + #[test] + fn stride_8232_offsets_and_unit_roundtrip() { + let mut io = PageIo::open_in_memory().unwrap(); + io.set_stride(ENC_PAGE_SIZE); + assert_eq!(io.stride(), ENC_PAGE_SIZE); + + // Distinct 8232-byte blobs per page id. + let mut blob0 = vec![0u8; ENC_PAGE_SIZE]; + blob0[0] = 0xA0; + blob0[ENC_PAGE_SIZE - 1] = 0x0A; + let mut blob2 = vec![0u8; ENC_PAGE_SIZE]; + blob2[0] = 0xC2; + blob2[ENC_PAGE_SIZE - 1] = 0x2C; + + io.write_page_unit(0, &blob0).unwrap(); + io.write_page_unit(2, &blob2).unwrap(); // page 1 zero-filled by growth + + // page_count is in stride-units: writing page 2 extends to 3. + assert_eq!(io.page_count().unwrap(), 3); + + assert_eq!(io.read_page_unit(0).unwrap(), blob0); + assert_eq!(io.read_page_unit(2).unwrap(), blob2); + // The zero-filled gap page reads back as all zeros. + assert_eq!(io.read_page_unit(1).unwrap(), vec![0u8; ENC_PAGE_SIZE]); + } + + // The plaintext stride (default) keeps PAGE_SIZE offset math intact. + #[test] + fn default_stride_is_page_size() { + let io = PageIo::open_in_memory().unwrap(); + assert_eq!(io.stride(), PAGE_SIZE); + } + + // A blob whose length != stride is a caller bug, not silent truncation. + #[test] + fn write_unit_wrong_length_is_invalid() { + let mut io = PageIo::open_in_memory().unwrap(); + io.set_stride(ENC_PAGE_SIZE); + let short = vec![0u8; PAGE_SIZE]; // wrong: 8192 != 8232 + assert!(io.write_page_unit(0, &short).is_err()); + } + + // set_stride re-seeds page_count in the new unit size. + #[test] + fn set_stride_reseeds_page_count_in_memory() { + let mut io = PageIo::open_in_memory().unwrap(); + // Write 2 plaintext pages (stride == PAGE_SIZE by default). + io.write_page(0, &[0u8; PAGE_SIZE]).unwrap(); + io.write_page(1, &[0u8; PAGE_SIZE]).unwrap(); + assert_eq!(io.page_count().unwrap(), 2); + + // After switching to ENC_PAGE_SIZE stride: 2 * 8192 = 16384 bytes. + // 16384 / 8232 = 1 full unit (remainder discarded by integer div). + io.set_stride(ENC_PAGE_SIZE); + assert_eq!(io.stride(), ENC_PAGE_SIZE); + assert_eq!(io.page_count().unwrap(), 1); + } + + // set_page_count uses stride so the file length is n * stride bytes. + #[test] + fn set_page_count_uses_stride() { + let mut io = PageIo::open_in_memory().unwrap(); + io.set_stride(ENC_PAGE_SIZE); + io.set_page_count(3).unwrap(); + assert_eq!(io.page_count().unwrap(), 3); + // The flat byte vec must be exactly 3 * 8232 bytes. + // Verify indirectly: reading page 2 (zero-filled) must succeed. + assert_eq!(io.read_page_unit(2).unwrap(), vec![0u8; ENC_PAGE_SIZE]); + // And page 3 must be out-of-range. + assert!(matches!( + io.read_page_unit(3), + Err(ChiselError::InvalidPageId { page_id: 3 }) + )); + } + + // Plaintext read_page/write_page still work unchanged with the default stride. + #[test] + fn plaintext_wrappers_unchanged_at_default_stride() { + use tempfile::NamedTempFile; + let f = NamedTempFile::new().unwrap(); + let mut io = PageIo::open(f.path(), false).unwrap(); + assert_eq!(io.stride(), PAGE_SIZE); + + let mut buf = [0u8; PAGE_SIZE]; + buf[0] = 0x77; + buf[PAGE_SIZE - 1] = 0x99; + io.write_page(0, &buf).unwrap(); + io.write_page(1, &[0xAB; PAGE_SIZE]).unwrap(); + + assert_eq!(io.page_count().unwrap(), 2); + assert_eq!(io.read_page(0).unwrap(), buf); + assert_eq!(io.read_page(1).unwrap(), [0xAB; PAGE_SIZE]); + } +} From c3faf2b63163ef0d0f9cddef596d63b5246fe476 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 09:56:27 -0700 Subject: [PATCH 21/42] feat(spillway): parameterize slot by payload_size for sealed-blob support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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. --- src/page_cache.rs | 26 ++++-- src/spillway.rs | 209 +++++++++++++++++++++++++++++++--------------- 2 files changed, 160 insertions(+), 75 deletions(-) diff --git a/src/page_cache.rs b/src/page_cache.rs index a44c75a..ed5f506 100644 --- a/src/page_cache.rs +++ b/src/page_cache.rs @@ -470,17 +470,20 @@ impl PageCache { // effectively rolled back. If commit is ever made retryable, // reorder to write_page-then-forget so a failed drain leaves the // page recoverable from the spillway (review 2026-06-22). - let buf = { + let buf: Box<[u8; page::PAGE_SIZE]> = { let spw = self.spillway.as_mut().unwrap(); let b = spw.rehydrate(page_id)?; spw.forget(page_id); - b + // rehydrate returns Vec; for a plaintext spillway it is + // always PAGE_SIZE bytes. Task 3.3/3.4 replaces this with + // the sealed-blob path; unwrap is safe until then. + Box::new(b.try_into().expect("spillway rehydrate returned wrong length")) }; self.io.write_page(page_id, &buf)?; // Re-insert as clean: the bytes are now on the main file, // so the cache entry is a valid read-through cache. let entry = CacheEntry { - buf: Box::new(buf), + buf, dirty: false, }; // Use the Entry API to avoid the clippy::map_entry pattern: @@ -871,12 +874,17 @@ impl PageCache { // pre-transaction bytes. if let Some(spw) = self.spillway.as_mut() { if spw.is_resident(page_id) { - let buf = spw.rehydrate(page_id)?; - spw.forget(page_id); + let buf: Box<[u8; page::PAGE_SIZE]> = { + let b = spw.rehydrate(page_id)?; + spw.forget(page_id); + // rehydrate returns Vec; for plaintext always PAGE_SIZE. + // Task 3.3/3.4 replaces with the sealed-blob path. + Box::new(b.try_into().expect("spillway rehydrate returned wrong length")) + }; self.entries.insert( page_id, CacheEntry { - buf: Box::new(buf), + buf, dirty: true, // re-loaded spilled page is dirty }, ); @@ -1044,7 +1052,7 @@ impl PageCache { // is restored the same way. let spill_result = self .ensure_spillway() - .and_then(|spw| spw.spill(victim_id, &entry.buf)); + .and_then(|spw| spw.spill(victim_id, entry.buf.as_ref())); if let Err(e) = spill_result { if entry.dirty { self.dirty_count += 1; @@ -1084,10 +1092,10 @@ impl PageCache { if self.spillway.is_none() { let spw = match &self.spillway_location { crate::SpillwayLocation::Path(p) => { - crate::spillway::Spillway::open_file(p, self.spillway_max_bytes)? + crate::spillway::Spillway::open_file(p, self.spillway_max_bytes, page::PAGE_SIZE)? } crate::SpillwayLocation::InMemory => { - crate::spillway::Spillway::open_memory(self.spillway_max_bytes) + crate::spillway::Spillway::open_memory(self.spillway_max_bytes, page::PAGE_SIZE) } }; self.spillway = Some(spw); diff --git a/src/spillway.rs b/src/spillway.rs index 63f4afa..1519dd0 100644 --- a/src/spillway.rs +++ b/src/spillway.rs @@ -15,10 +15,19 @@ // truncate file shrunk to zero, resident-set index cleared. Called // at commit, rollback, and defrag. // -// Slot layout (PAGE_SIZE + 16 bytes): +// Slot layout (payload_size + SLOT_HEADER_SIZE bytes): // u64 page_id (the main-file page id this slot shadows) -// u64 checksum (XXH3 over (page_id || page_bytes)) -// [u8] page bytes (PAGE_SIZE = 8192 bytes) +// u64 checksum (XXH3 over (page_id || payload bytes)) +// [u8] payload (PAGE_SIZE = 8192 plaintext, ENC_PAGE_SIZE = 8232 sealed) +// +// The spillway is crypto-agnostic: it stores whatever payload bytes it is +// handed. For plaintext DBs the payload is an 8192-byte page. For encrypted +// DBs the payload is the 8232-byte sealed blob (ct‖tag‖nonce) produced by +// PageCipher::seal before spill and consumed by PageCipher::open after +// rehydrate — seal-once semantics: the blob is stored verbatim and drain +// copies it verbatim to the main file. The per-slot XXH3 checksum is +// distinct from the AEAD tag inside the sealed blob: it catches a torn +// spillway write before the blob reaches the main file. // // On-disk format is little-endian (matches the main-file convention). // @@ -39,7 +48,10 @@ use crate::page::PAGE_SIZE; /// Per-slot header: u64 page_id + u64 XXH3 checksum. pub const SLOT_HEADER_SIZE: usize = 16; -/// Total bytes a slot occupies on disk (header + page). +/// Default slot size for a plaintext DB (header + PAGE_SIZE). Used by the +/// test suite and by callers that pass PAGE_SIZE as payload_size. Task 3.3/3.4 +/// uses Spillway::slot_size() instead once the payload_size varies at runtime. +#[allow(dead_code)] pub const SLOT_SIZE: usize = SLOT_HEADER_SIZE + PAGE_SIZE; /// Spillway backing storage: real file on disk, or in-memory bytes for @@ -64,7 +76,7 @@ pub struct Spillway { /// /// For the FILE backing, the cursor directly maps to the on-disk write /// position. For the MEMORY backing, the `Vec` in `Backing::Memory` - /// grows by SLOT_SIZE for every distinct page spilled within a transaction + /// grows by slot_size for every distinct page spilled within a transaction /// (forget/respill of the same page reuses an existing slot and does NOT /// shrink the vec). A long transaction that forget/respills many distinct /// pages therefore grows the in-memory vec cumulatively — bounded by the @@ -73,11 +85,18 @@ pub struct Spillway { /// all of it; the risk is peak memory during a single large transaction. next_slot_index: u64, /// Strict upper bound on the LIVE resident set's logical size in bytes, - /// excluding per-slot headers (`slots.len() * PAGE_SIZE`). Captured at + /// excluding per-slot headers (`slots.len() * payload_size`). Captured at /// construction; runtime-mutable via PageCache::set_spillway_max_bytes. The /// physical backing file may transiently exceed this by the unforgotten /// write-cursor tail, which `truncate` reclaims at commit/rollback. max_bytes: u64, + /// Bytes per payload: PAGE_SIZE for plaintext, ENC_PAGE_SIZE for encrypted. + /// On an encrypted DB the payload IS the sealed `ct‖tag‖nonce` blob — the + /// spillway stores ciphertext and drain copies it verbatim (seal-once). The + /// slot is `SLOT_HEADER_SIZE + payload_size` bytes; the per-slot XXH3 + /// checksum covers the payload, catching a torn spillway write before the + /// blob reaches the main file (distinct from the inner AEAD tag). + payload_size: usize, } impl Spillway { @@ -85,7 +104,10 @@ impl Spillway { /// main database. The path is `.spillway`. Any pre-existing /// content is discarded — no superblock can possibly point at /// spillway bytes, so this is always safe. - pub fn open_file(db_path: &Path, max_bytes: u64) -> Result { + /// + /// `payload_size` is `PAGE_SIZE` for plaintext DBs and `ENC_PAGE_SIZE` + /// for encrypted DBs. It determines the slot size and capacity accounting. + pub fn open_file(db_path: &Path, max_bytes: u64, payload_size: usize) -> Result { // I65: build the spillway path as an OsString and pass it to // OpenOptions directly — OsString impls AsRef, so we // don't need a PathBuf round-trip. The path is not retained @@ -115,17 +137,22 @@ impl Spillway { slots: HashMap::new(), next_slot_index: 0, max_bytes, + payload_size, }) } /// Open a memory-backed spillway. Used by `Chisel::open_in_memory`. /// Drops on close like the rest of memory mode. - pub fn open_memory(max_bytes: u64) -> Spillway { + /// + /// `payload_size` is `PAGE_SIZE` for plaintext DBs and `ENC_PAGE_SIZE` + /// for encrypted DBs. + pub fn open_memory(max_bytes: u64, payload_size: usize) -> Spillway { Spillway { backing: Backing::Memory { bytes: Vec::new() }, slots: HashMap::new(), next_slot_index: 0, max_bytes, + payload_size, } } @@ -147,16 +174,16 @@ impl Spillway { /// Logical size of the LIVE resident set in bytes (excludes per-slot /// headers). Charged against `slots.len()`, not the monotonic write cursor /// `next_slot_index`: a spill-then-`forget`-then-respill cycle advances the - /// cursor every time but the live set may stay small, so the cursor would - /// over-report. This is the figure `SpillwayFull` is judged against, so the - /// two must agree (see `spill`). The physical backing file can be larger - /// than this — the unforgotten tail is garbage reclaimed by `truncate`. + /// cursor without growing the live set, so the cursor would over-report. + /// This is the figure `SpillwayFull` is judged against, so the two must + /// agree (see `spill`). The physical backing file can be larger than this — + /// the unforgotten tail is garbage reclaimed by `truncate`. /// /// I74 (ISSUES.md, 2026-05-22): exposed via `Chisel::stats` / /// `Stats::spillway_logical_bytes` so operators can monitor spillway /// capacity use and predict `SpillwayFull` before it fires. pub fn logical_bytes(&self) -> u64 { - self.slots.len() as u64 * PAGE_SIZE as u64 + self.slots.len() as u64 * self.payload_size as u64 } /// Strict upper bound on logical size, settable at construction or @@ -176,22 +203,31 @@ impl Spillway { self.max_bytes = bytes; } - /// Write `page_bytes` to this spillway, keyed by `page_id`. If the - /// page is already resident, overwrites its existing slot in place - /// (no slot-count growth, no max_bytes check). Otherwise allocates - /// a new slot at `next_slot_index` — but first checks that the - /// post-write LIVE size stays within `max_bytes`. - pub fn spill(&mut self, page_id: u64, page_bytes: &[u8; PAGE_SIZE]) -> Result<()> { + /// On-disk slot size in bytes: `SLOT_HEADER_SIZE + payload_size`. + /// Used by Task 3.3/3.4 to size drain buffers for encrypted DBs. + #[allow(dead_code)] + pub fn slot_size(&self) -> usize { + SLOT_HEADER_SIZE + self.payload_size + } + + /// Write `blob` to this spillway, keyed by `page_id`. `blob` must be + /// exactly `payload_size` bytes. If the page is already resident, + /// overwrites its existing slot in place (no slot-count growth, no + /// max_bytes check). Otherwise allocates a new slot at `next_slot_index` + /// — but first checks that the post-write LIVE size stays within + /// `max_bytes`. + pub fn spill(&mut self, page_id: u64, blob: &[u8]) -> Result<()> { + debug_assert_eq!(blob.len(), self.payload_size, "spill blob != payload_size"); let slot_index = if let Some(&existing) = self.slots.get(&page_id) { existing } else { - // Adding a new live page push the LIVE resident set past the cap? + // Adding a new live page: would the LIVE resident set push past the cap? // Charge the cap against `slots.len()` (live residency), not the // monotonic write cursor: a forget/respill cycle climbs the cursor // without growing the live set, so a cursor-based cap would trip // spuriously. `next_slot_index` still advances (no slot reuse // mid-transaction); its tail garbage is reclaimed by `truncate`. - let post_write_bytes = (self.slots.len() as u64 + 1) * PAGE_SIZE as u64; + let post_write_bytes = (self.slots.len() as u64 + 1) * self.payload_size as u64; if post_write_bytes > self.max_bytes { return Err(ChiselError::SpillwayFull { limit_bytes: self.max_bytes, @@ -203,7 +239,7 @@ impl Spillway { new_index }; - write_slot(&mut self.backing, slot_index, page_id, page_bytes)?; + write_slot(&mut self.backing, slot_index, page_id, blob, self.payload_size)?; Ok(()) } @@ -266,17 +302,18 @@ impl Spillway { } /// Read the slot for `page_id`, verify the per-slot checksum, return - /// the bytes. Returns `ChecksumMismatch { page_id }` (fatal) on a - /// torn write — caller poisons the transaction. Returns - /// `InvalidPageId { page_id }` if the page is not resident - /// (programming error in the caller, not a torn-write). - pub fn rehydrate(&mut self, page_id: u64) -> Result<[u8; PAGE_SIZE]> { + /// the payload bytes as a `Vec` of length `payload_size`. Returns + /// `ChecksumMismatch { page_id }` (fatal) on a torn write — caller + /// poisons the transaction. Returns `InvalidPageId { page_id }` if the + /// page is not resident (programming error in the caller, not a + /// torn-write). + pub fn rehydrate(&mut self, page_id: u64) -> Result> { let slot_index = match self.slots.get(&page_id) { Some(&i) => i, None => return Err(ChiselError::InvalidPageId { page_id }), }; - let (stored_page_id, stored_checksum, page_bytes) = - read_slot(&mut self.backing, slot_index)?; + let (stored_page_id, stored_checksum, blob) = + read_slot(&mut self.backing, slot_index, self.payload_size)?; // Sanity check: the slot's stored page_id must match what the // resident-set says it should be. A mismatch implies in-memory @@ -285,21 +322,24 @@ impl Spillway { if stored_page_id != page_id { return Err(ChiselError::ChecksumMismatch { page_id }); } - let computed = slot_checksum(page_id, &page_bytes); + let computed = slot_checksum(page_id, &blob); if computed != stored_checksum { return Err(ChiselError::ChecksumMismatch { page_id }); } - Ok(page_bytes) + Ok(blob) } } -/// Compute the per-slot checksum: XXH3 over (page_id || page_bytes). +/// Compute the per-slot checksum: XXH3 over (page_id || blob). /// Distinct from the main-file page checksum because a spilled page -/// may not yet have a stamped main-file checksum (see spec). -fn slot_checksum(page_id: u64, page_bytes: &[u8; PAGE_SIZE]) -> u64 { +/// may not yet have a stamped main-file checksum (see spec). For +/// encrypted DBs the blob is the sealed ciphertext; the checksum +/// covers the sealed bytes, guarding the spillway round-trip +/// independently of the AEAD tag inside the blob. +fn slot_checksum(page_id: u64, blob: &[u8]) -> u64 { let mut hasher = xxhash_rust::xxh3::Xxh3::new(); hasher.update(&page_id.to_le_bytes()); - hasher.update(page_bytes); + hasher.update(blob); hasher.digest() } @@ -307,10 +347,12 @@ fn write_slot( backing: &mut Backing, slot_index: u64, page_id: u64, - page_bytes: &[u8; PAGE_SIZE], + blob: &[u8], + payload_size: usize, ) -> Result<()> { - let checksum = slot_checksum(page_id, page_bytes); - let offset = slot_index * SLOT_SIZE as u64; + let slot_size = SLOT_HEADER_SIZE + payload_size; + let checksum = slot_checksum(page_id, blob); + let offset = slot_index * slot_size as u64; let mut header = [0u8; SLOT_HEADER_SIZE]; header[..8].copy_from_slice(&page_id.to_le_bytes()); header[8..16].copy_from_slice(&checksum.to_le_bytes()); @@ -318,53 +360,58 @@ fn write_slot( Backing::File { file } => { file.seek(SeekFrom::Start(offset))?; file.write_all(&header)?; - file.write_all(page_bytes)?; + file.write_all(blob)?; } Backing::Memory { bytes } => { // The vec grows to accommodate `slot_index` monotonically; it is // never trimmed mid-transaction. See `next_slot_index` field doc // for the cumulative-growth worst-case in memory mode. - let needed = (offset + SLOT_SIZE as u64) as usize; + let needed = (offset + slot_size as u64) as usize; if bytes.len() < needed { bytes.resize(needed, 0); } let off = offset as usize; bytes[off..off + SLOT_HEADER_SIZE].copy_from_slice(&header); - bytes[off + SLOT_HEADER_SIZE..off + SLOT_SIZE].copy_from_slice(page_bytes); + bytes[off + SLOT_HEADER_SIZE..off + slot_size].copy_from_slice(blob); } } Ok(()) } -/// Read the (page_id, checksum, page_bytes) triple from the given slot. +/// Read the (page_id, checksum, blob) triple from the given slot. /// Symmetric counterpart to write_slot — same offset arithmetic, same /// backing dispatch. Returns IoError on short read (underlying I/O /// failure) rather than ChecksumMismatch; callers distinguish the two. -fn read_slot(backing: &mut Backing, slot_index: u64) -> Result<(u64, u64, [u8; PAGE_SIZE])> { - let offset = slot_index * SLOT_SIZE as u64; +fn read_slot( + backing: &mut Backing, + slot_index: u64, + payload_size: usize, +) -> Result<(u64, u64, Vec)> { + let slot_size = SLOT_HEADER_SIZE + payload_size; + let offset = slot_index * slot_size as u64; let mut header = [0u8; SLOT_HEADER_SIZE]; - let mut page_bytes = [0u8; PAGE_SIZE]; + let mut blob = vec![0u8; payload_size]; match backing { Backing::File { file } => { file.seek(SeekFrom::Start(offset))?; file.read_exact(&mut header)?; - file.read_exact(&mut page_bytes)?; + file.read_exact(&mut blob)?; } Backing::Memory { bytes } => { let off = offset as usize; - if bytes.len() < off + SLOT_SIZE { + if bytes.len() < off + slot_size { return Err(ChiselError::IoError(std::io::Error::new( std::io::ErrorKind::UnexpectedEof, format!("spillway memory backing too short for slot {slot_index}"), ))); } header.copy_from_slice(&bytes[off..off + SLOT_HEADER_SIZE]); - page_bytes.copy_from_slice(&bytes[off + SLOT_HEADER_SIZE..off + SLOT_SIZE]); + blob.copy_from_slice(&bytes[off + SLOT_HEADER_SIZE..off + slot_size]); } } let stored_page_id = u64::from_le_bytes(header[..8].try_into().unwrap()); let stored_checksum = u64::from_le_bytes(header[8..16].try_into().unwrap()); - Ok((stored_page_id, stored_checksum, page_bytes)) + Ok((stored_page_id, stored_checksum, blob)) } #[cfg(test)] @@ -388,7 +435,7 @@ mod tests { // process" — open_file must overwrite it. std::fs::write(&spillway_path, b"garbage").unwrap(); - let spw = Spillway::open_file(&db_path, 1024 * 1024).unwrap(); + let spw = Spillway::open_file(&db_path, 1024 * 1024, PAGE_SIZE).unwrap(); assert!(!spw.is_resident(42)); assert_eq!(spw.slot_count(), 0); assert_eq!(spw.logical_bytes(), 0); @@ -403,7 +450,7 @@ mod tests { #[test] fn open_memory_starts_empty() { - let spw = Spillway::open_memory(1024 * 1024); + let spw = Spillway::open_memory(1024 * 1024, PAGE_SIZE); assert!(!spw.is_resident(0)); assert_eq!(spw.slot_count(), 0); assert_eq!(spw.logical_bytes(), 0); @@ -412,18 +459,18 @@ mod tests { #[test] fn set_max_bytes_updates_cap() { - let mut spw = Spillway::open_memory(1024); + let mut spw = Spillway::open_memory(1024, PAGE_SIZE); spw.set_max_bytes(2048); assert_eq!(spw.max_bytes(), 2048); } - fn page(byte: u8) -> [u8; PAGE_SIZE] { - [byte; PAGE_SIZE] + fn page(byte: u8) -> Vec { + vec![byte; PAGE_SIZE] } #[test] fn spill_inserts_new_slot() { - let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4); + let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE); spw.spill(100, &page(0xAA)).unwrap(); assert!(spw.is_resident(100)); assert_eq!(spw.slot_count(), 1); @@ -432,7 +479,7 @@ mod tests { #[test] fn re_spill_of_resident_page_reuses_slot() { - let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4); + let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE); spw.spill(100, &page(0xAA)).unwrap(); spw.spill(100, &page(0xBB)).unwrap(); // overwrite assert_eq!(spw.slot_count(), 1, "slot count must not grow on re-spill"); @@ -442,7 +489,7 @@ mod tests { fn spill_full_returns_spillway_full_error() { // max_bytes accommodates exactly 2 page payloads (excluding header). let max_bytes = (PAGE_SIZE * 2) as u64; - let mut spw = Spillway::open_memory(max_bytes); + let mut spw = Spillway::open_memory(max_bytes, PAGE_SIZE); spw.spill(100, &page(0xAA)).unwrap(); spw.spill(101, &page(0xBB)).unwrap(); let err = spw.spill(102, &page(0xCC)).unwrap_err(); @@ -466,7 +513,7 @@ mod tests { // monotonic (the file tail is reclaimed at `truncate`), so this is an // accounting fix only — no slot reuse, no double-free. let max_bytes = (PAGE_SIZE * 2) as u64; // room for 2 LIVE pages - let mut spw = Spillway::open_memory(max_bytes); + let mut spw = Spillway::open_memory(max_bytes, PAGE_SIZE); // Spill-then-forget far more than 2 distinct pages: live residency // never exceeds 1, so the 2-page cap is never reached. for id in 0..100u64 { @@ -488,7 +535,7 @@ mod tests { #[test] fn rehydrate_round_trips_bytes() { - let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4); + let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE); let original = page(0xAB); spw.spill(100, &original).unwrap(); let restored = spw.rehydrate(100).unwrap(); @@ -497,7 +544,7 @@ mod tests { #[test] fn rehydrate_after_overwrite_returns_latest_bytes() { - let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4); + let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE); spw.spill(100, &page(0xAA)).unwrap(); spw.spill(100, &page(0xBB)).unwrap(); let restored = spw.rehydrate(100).unwrap(); @@ -506,14 +553,14 @@ mod tests { #[test] fn rehydrate_missing_page_returns_invalid_page_id() { - let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4); + let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE); let err = spw.rehydrate(999).unwrap_err(); assert!(matches!(err, ChiselError::InvalidPageId { page_id: 999 })); } #[test] fn rehydrate_with_corrupted_byte_returns_checksum_mismatch() { - let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4); + let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE); spw.spill(100, &page(0xAA)).unwrap(); // Corrupt the page bytes directly (simulating a torn write). if let Backing::Memory { ref mut bytes } = spw.backing { @@ -529,7 +576,7 @@ mod tests { #[test] fn truncate_clears_residents_and_resets_index() { - let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4); + let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE); spw.spill(100, &page(0xAA)).unwrap(); spw.spill(101, &page(0xBB)).unwrap(); assert_eq!(spw.slot_count(), 2); @@ -547,7 +594,7 @@ mod tests { #[test] fn drain_batch_returns_resident_ids_up_to_batch_size() { - let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 8); + let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 8, PAGE_SIZE); for id in 100..105 { spw.spill(id, &page(id as u8)).unwrap(); } @@ -560,7 +607,7 @@ mod tests { #[test] fn forget_above_drops_high_ids_only() { - let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 8); + let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 8, PAGE_SIZE); for id in 0..6 { spw.spill(id, &page(id as u8)).unwrap(); } @@ -575,10 +622,40 @@ mod tests { #[test] fn forget_drops_from_resident_set() { - let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4); + let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE); spw.spill(100, &page(0xAA)).unwrap(); assert!(spw.is_resident(100)); spw.forget(100); assert!(!spw.is_resident(100)); } + + #[test] + fn wide_slot_round_trips_sealed_blob() { + use crate::crypto::ENC_PAGE_SIZE; + // payload_size = ENC_PAGE_SIZE: each slot carries an 8232-byte sealed + // blob plus the 16-byte header. Round-trip must return the exact bytes. + let slot = (SLOT_HEADER_SIZE + ENC_PAGE_SIZE) as u64; + let mut spw = Spillway::open_memory(slot * 4, ENC_PAGE_SIZE); + let mut blob = vec![0u8; ENC_PAGE_SIZE]; + blob[0] = 0xEE; + blob[ENC_PAGE_SIZE - 1] = 0x11; + spw.spill(7, &blob).unwrap(); + assert!(spw.is_resident(7)); + assert_eq!(spw.rehydrate(7).unwrap(), blob); + } + + #[test] + fn wide_slot_checksum_catches_tampered_payload() { + use crate::crypto::ENC_PAGE_SIZE; + let slot = (SLOT_HEADER_SIZE + ENC_PAGE_SIZE) as u64; + let mut spw = Spillway::open_memory(slot * 4, ENC_PAGE_SIZE); + spw.spill(7, &vec![0xAB; ENC_PAGE_SIZE]).unwrap(); + if let Backing::Memory { ref mut bytes } = spw.backing { + bytes[SLOT_HEADER_SIZE + 5] ^= 0x01; // flip a byte in the blob + } + assert!(matches!( + spw.rehydrate(7).unwrap_err(), + ChiselError::ChecksumMismatch { page_id: 7 } + )); + } } From 37ba9837460bd123e9e4a2f1431e1abfcdd07075 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 11:26:28 -0700 Subject: [PATCH 22/42] feat(page_cache): wire PageCipher seal/open + uniform 8232 stride (Task 3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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; 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). --- src/crypto/mod.rs | 10 +- src/page_cache.rs | 201 +++++++++++++++++++++++++++++++++--- src/page_io.rs | 51 +++++++-- src/transaction/commit.rs | 16 ++- src/transaction/mod.rs | 10 +- src/transaction/recovery.rs | 166 +++++++++++++++++++++++++---- tests/encryption_open.rs | 137 ++++++++++++++++++++++++ 7 files changed, 540 insertions(+), 51 deletions(-) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index a1087f4..5cc884e 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -10,11 +10,6 @@ // All randomness is OS-sourced (getrandom). Rolling our own crypto is // forbidden; only the vetted RustCrypto primitives are used. -// ponytail: staged build — later tasks (PageCipher, KDF, wrap/unwrap) consume -// these types; suppress dead_code until the callers land rather than scattering -// #[allow] on every item. -#![allow(dead_code)] - use chacha20poly1305::aead::AeadInPlace; use chacha20poly1305::{Key as AeadKey, KeyInit, XChaCha20Poly1305, XNonce}; use zeroize::Zeroizing; @@ -290,6 +285,11 @@ pub fn random_dek() -> Dek { /// whole-page (fixed 8192→8232) and variable-length body (superblock sub-blob). /// Lives in the page-cache layer in later phases; here it is fully standalone. /// Constructs the AEAD cipher once and reuses it across calls. +/// +/// `Clone` produces an independent copy with its own `Zeroizing` DEK (both +/// copies wipe on drop independently). Used when the cache and the session +/// manager each need their own cipher instance from the same DEK. +#[derive(Clone)] pub struct PageCipher { dek: Dek, } diff --git a/src/page_cache.rs b/src/page_cache.rs index ed5f506..3dbacf9 100644 --- a/src/page_cache.rs +++ b/src/page_cache.rs @@ -41,6 +41,7 @@ use std::cell::Cell; use rustc_hash::FxHashMap; +use crate::crypto::ENC_PAGE_SIZE; use crate::error::{ChiselError, Result}; use crate::lru::LruIndex; use crate::page::{self, PAGE_SIZE}; @@ -122,6 +123,13 @@ pub struct PageCache { // Monotonically increasing allocator for shadow-paged new pages. Never // reused within a process lifetime except via `truncate()`. next_page_id: u64, + /// Page sealer/opener for encrypted DBs; `None` for plaintext. When `Some`, + /// `entries` always holds PLAINTEXT while the main file and spillway hold + /// CIPHERTEXT. Seal happens once at flush (Phase 1a) and at evict-to-spillway + /// (Task 3.4); open happens at cold load before `verify_checksum`. The caller + /// MUST have already called `io.set_stride(ENC_PAGE_SIZE)` before installing + /// the cipher so that on-disk offsets use the 8232-byte encrypted stride. + cipher: Option, // Cumulative-from-open counters. Cell so reads can go through // `&self` accessors (forward-compatible with a possible future where // get/new_page also become &self via interior mutability — today they @@ -179,6 +187,7 @@ impl PageCache { spillway_location, spillway: None, next_page_id, + cipher: None, cache_hits: Cell::new(0), cache_misses: Cell::new(0), pages_allocated: Cell::new(0), @@ -412,9 +421,16 @@ impl PageCache { // self.entries (filtered by dirty=true). Nothing between // the extend and this loop touches self.entries, so each // id is still present and still dirty. - let entry = self.entries.get_mut(&page_id).unwrap(); - self.io.write_page(page_id, &entry.buf)?; - entry.dirty = false; + // + // Copy the plaintext out before calling write_sealed: write_sealed + // needs `&mut self` (to call self.io) while `entry` borrows + // self.entries. The 8 KB stack copy is the same cost as the COW + // paths elsewhere in the cache that already pay it. + let plaintext: [u8; PAGE_SIZE] = *self.entries.get(&page_id).unwrap().buf; + self.write_sealed(page_id, &plaintext)?; + // Mark clean only AFTER the write succeeds. On error the page + // stays dirty and the I1 poison model discards it. + self.entries.get_mut(&page_id).unwrap().dirty = false; } dirty_scratch.clear(); self.dirty_scratch = dirty_scratch; @@ -470,20 +486,23 @@ impl PageCache { // effectively rolled back. If commit is ever made retryable, // reorder to write_page-then-forget so a failed drain leaves the // page recoverable from the spillway (review 2026-06-22). - let buf: Box<[u8; page::PAGE_SIZE]> = { + let plaintext: Box<[u8; page::PAGE_SIZE]> = { let spw = self.spillway.as_mut().unwrap(); let b = spw.rehydrate(page_id)?; spw.forget(page_id); - // rehydrate returns Vec; for a plaintext spillway it is - // always PAGE_SIZE bytes. Task 3.3/3.4 replaces this with - // the sealed-blob path; unwrap is safe until then. + // rehydrate returns Vec; for a plaintext spillway the + // payload is always PAGE_SIZE bytes. Task 3.4 replaces this + // with the sealed-blob path (spillway will hold ciphertext); + // until then, the spillway always holds plaintext. Box::new(b.try_into().expect("spillway rehydrate returned wrong length")) }; - self.io.write_page(page_id, &buf)?; + // write_sealed handles both plaintext and encrypted stride; it + // must not be write_page (which asserts stride == PAGE_SIZE). + self.write_sealed(page_id, &plaintext)?; // Re-insert as clean: the bytes are now on the main file, // so the cache entry is a valid read-through cache. let entry = CacheEntry { - buf, + buf: plaintext, dirty: false, }; // Use the Entry API to avoid the clippy::map_entry pattern: @@ -834,6 +853,33 @@ impl PageCache { self.drain_insertion = policy; } + /// Install the page cipher for an encrypted DB. The caller MUST have + /// already called `self.io_mut().set_stride(ENC_PAGE_SIZE)` so that + /// on-disk offset arithmetic uses the 8232-byte encrypted stride. + /// Set once at open time after the DEK is unwrapped (or generated). + pub fn set_cipher(&mut self, cipher: crate::crypto::PageCipher) { + self.cipher = Some(cipher); + } + + /// Seal a plaintext page and write its on-disk unit. + /// + /// For an encrypted DB (cipher present): seals the 8192-byte plaintext + /// into the 8232-byte `ct‖tag‖nonce` blob then calls `write_page_unit`. + /// For a plaintext DB: calls `write_page_unit` directly with the 8192-byte + /// image (stride == PAGE_SIZE, so the unit is the page image itself). + /// + /// This is the single seal point shared by flush Phase 1a. Task 3.4 will + /// add the evict-to-spillway call here as well. + fn write_sealed(&mut self, page_id: u64, plaintext: &[u8; PAGE_SIZE]) -> Result<()> { + match &self.cipher { + Some(c) => { + let blob = c.seal(page_id, plaintext); + self.io.write_page_unit(page_id, &blob) + } + None => self.io.write_page_unit(page_id, plaintext), + } + } + /// Check if a page is dirty in the cache. /// /// Used by the transaction layer to reason about whether a page is @@ -897,14 +943,40 @@ impl PageCache { // Fall through to disk: the page is not spilled, so its // last-committed bytes live in the main file (or the page id // is bogus, in which case PageIo will surface it). - let buf = self.io.read_page(page_id)?; - if !page::verify_checksum(&buf) { + // + // Use a stack-allocated ENC_PAGE_SIZE buffer (the maximum on-disk unit) + // and read only the first `stride` bytes — no heap allocation on this + // hot path regardless of whether the DB is encrypted. The cipher branch + // verifies the AEAD tag (anti-tamper) before the plaintext is cached; + // the plaintext branch runs verify_checksum on the raw page bytes as before. + let mut on_disk = [0u8; ENC_PAGE_SIZE]; + let stride = self.io.stride(); + self.io.read_page_unit_into(page_id, &mut on_disk[..stride])?; + + let plaintext: [u8; PAGE_SIZE] = match &self.cipher { + Some(c) => { + // The on-disk unit is exactly ENC_PAGE_SIZE at this stride; + // the try_into is infallible (ENC_PAGE_SIZE == 8232 == stride). + let unit: [u8; ENC_PAGE_SIZE] = on_disk; + c.open(page_id, &unit) + .map_err(|_| ChiselError::DecryptionFailed { page_id })? + } + // Plaintext: the first PAGE_SIZE bytes of the on_disk buffer ARE the + // page image (stride == PAGE_SIZE here, so the copy takes exactly 8192 bytes). + None => { + let mut buf = [0u8; PAGE_SIZE]; + buf.copy_from_slice(&on_disk[..PAGE_SIZE]); + buf + } + }; + + if !page::verify_checksum(&plaintext) { return Err(ChiselError::ChecksumMismatch { page_id }); } self.entries.insert( page_id, CacheEntry { - buf: Box::new(buf), + buf: Box::new(plaintext), dirty: false, }, ); @@ -1090,12 +1162,15 @@ impl PageCache { // otherwise create a sidecar file for what is supposed to be a // read-only open. if self.spillway.is_none() { + // ponytail: spillway holds plaintext until Task 3.4 adds seal-on-evict; + // payload_size is PAGE_SIZE for both plaintext and (current) encrypted DBs. + let payload_size = page::PAGE_SIZE; let spw = match &self.spillway_location { crate::SpillwayLocation::Path(p) => { - crate::spillway::Spillway::open_file(p, self.spillway_max_bytes, page::PAGE_SIZE)? + crate::spillway::Spillway::open_file(p, self.spillway_max_bytes, payload_size)? } crate::SpillwayLocation::InMemory => { - crate::spillway::Spillway::open_memory(self.spillway_max_bytes, page::PAGE_SIZE) + crate::spillway::Spillway::open_memory(self.spillway_max_bytes, payload_size) } }; self.spillway = Some(spw); @@ -1746,4 +1821,102 @@ mod tests { "phase-1a cleared the real dirty flags, not just the counter" ); } + + // ----------------------------------------------------------------------- + // Encrypted page-cache tests (Task 3.3) + // ----------------------------------------------------------------------- + + use crate::crypto::{random_dek, ENC_PAGE_SIZE, PageCipher}; + + /// Build a file-backed cache with stride=ENC_PAGE_SIZE and a PageCipher + /// installed. The stride must be set on the PageIo BEFORE construction so + /// page_count() is seeded correctly; the cipher is installed via set_cipher. + fn fresh_encrypted_cache(max_pages: usize) -> (TempDir, PageCache) { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("test.chisel"); + let mut io = PageIo::open(&db_path, false).unwrap(); + io.set_stride(ENC_PAGE_SIZE); + let cache_max_bytes = max_pages as u64 * PAGE_SIZE as u64; + let mut cache = PageCache::new( + io, + cache_max_bytes, + 0, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + cache.set_cipher(PageCipher::new(random_dek())); + (dir, cache) + } + + /// A plaintext data page written, flushed, evicted, and cold-loaded must + /// round-trip its exact bytes through the plaintext path (no cipher branch). + /// + /// NOTE: use offsets < CHECKSUM_OFFSET (8184) — stamp_checksum overwrites + /// bytes 8184..8192 with the XXH3 hash, which is what cold-load verifies. + #[test] + fn plaintext_page_roundtrip_unaffected() { + let (_dir, mut cache) = fresh_cache(8); + let pid = cache.new_page().unwrap(); + { + let buf = cache.get_mut(pid).unwrap(); + buf[0] = 0x11; + buf[4096] = 0xFF; // mid-page, before checksum region (8184..8192) + page::stamp_checksum(buf); + } + cache.flush().unwrap(); + cache.test_drop_from_cache(pid); + let read = cache.get(pid).unwrap(); + assert_eq!(read[0], 0x11); + assert_eq!(read[4096], 0xFF); + } + + /// Encrypted data-page round-trip: write → flush (seals to disk) → evict + /// → cold load (opens from disk) → plaintext bytes match original. + /// + /// NOTE: use offsets < CHECKSUM_OFFSET (8184) — stamp_checksum overwrites + /// bytes 8184..8192 with the XXH3 hash, which is what cold-load verifies. + #[test] + fn encrypted_page_round_trips_through_seal_open() { + let (_dir, mut cache) = fresh_encrypted_cache(8); + let pid = cache.new_page().unwrap(); + { + let buf = cache.get_mut(pid).unwrap(); + buf[0] = 0x9C; + buf[4096] = 0xC9; // middle of the page body, before the checksum region + page::stamp_checksum(buf); + } + cache.flush().unwrap(); + // Force a cold read: drop from cache so load_page hits the disk unit. + cache.test_drop_from_cache(pid); + let read = cache.get(pid).unwrap(); + assert_eq!(read[0], 0x9C, "first byte must survive seal/open"); + assert_eq!(read[4096], 0xC9, "mid-page byte must survive seal/open"); + } + + /// Tampering with a ciphertext byte must surface DecryptionFailed (AEAD + /// authentication failure). This proves the AEAD tag is verified on open. + #[test] + fn tampered_ciphertext_surfaces_decryption_failed() { + let (_dir, mut cache) = fresh_encrypted_cache(8); + let pid = cache.new_page().unwrap(); + { + let buf = cache.get_mut(pid).unwrap(); + buf[10] = 0x42; + page::stamp_checksum(buf); + } + cache.flush().unwrap(); + cache.test_drop_from_cache(pid); + // Flip one byte inside the 8192-byte ciphertext region (byte 42 of the + // 8232-byte on-disk unit, well before the 16-byte tag at bytes 8192..8208). + { + let mut blob = cache.io_mut().read_page_unit(pid).unwrap(); + blob[42] ^= 0x01; + cache.io_mut().write_page_unit(pid, &blob).unwrap(); + } + let err = cache.get(pid).unwrap_err(); + assert!( + matches!(err, ChiselError::DecryptionFailed { page_id } if page_id == pid), + "expected DecryptionFailed, got {err:?}" + ); + } } diff --git a/src/page_io.rs b/src/page_io.rs index 2dc5e7e..51aaf04 100644 --- a/src/page_io.rs +++ b/src/page_io.rs @@ -203,19 +203,15 @@ impl PageIo { } /// On-disk unit size in bytes (PAGE_SIZE plaintext, ENC_PAGE_SIZE encrypted). - // Dead until Task 3.3 wires the encrypted open path through set_stride. - #[allow(dead_code)] pub fn stride(&self) -> usize { self.stride } /// Set the on-disk stride and re-seed the page-count cache against the new /// unit size. Must be called BEFORE the first unit read on an encrypted DB. - /// The engine does this immediately after reading page 0's plaintext - /// bootstrap header. Re-seeds from the true file length so page_count() - /// is reported in the new stride-units. - // Dead until Task 3.3 wires the encrypted open path through set_stride. - #[allow(dead_code)] + /// The engine does this immediately after the superblock initialization so + /// data-page I/O uses the 8232-byte encrypted stride. Re-seeds from the + /// true file length so page_count() is reported in the new stride-units. pub fn set_stride(&mut self, stride: usize) { self.stride = stride; let len = match &mut self.backing { @@ -312,6 +308,47 @@ impl PageIo { } } + /// Read the raw on-disk unit into a caller-provided buffer. + /// + /// `buf` must be exactly `stride` bytes. Avoids the heap allocation of + /// `read_page_unit`; used by PageCache::load_page with a stack-allocated + /// `[u8; ENC_PAGE_SIZE]` buffer so the cold-load hot path allocates nothing. + /// + /// Shares all validation (bounds check, fault injection) with `read_page_unit`. + pub fn read_page_unit_into(&mut self, page_id: u64, buf: &mut [u8]) -> Result<()> { + let page_count = self.page_count()?; + if page_id >= page_count { + return Err(ChiselError::InvalidPageId { page_id }); + } + #[cfg(test)] + if self.fault.get() == Fault::FailReadPage(page_id) { + self.fault.set(Fault::None); + return Err(ChiselError::IoError(std::io::Error::other( + "fault-injected read failure", + ))); + } + let stride = self.stride; + debug_assert_eq!( + buf.len(), + stride, + "read_page_unit_into: buf.len() {} != stride {}", + buf.len(), + stride + ); + match &mut self.backing { + Backing::File { file } => { + let offset = page_id * stride as u64; + file.seek(SeekFrom::Start(offset))?; + file.read_exact(buf)?; + } + Backing::Memory { bytes } => { + let off = (page_id * stride as u64) as usize; + buf.copy_from_slice(&bytes[off..off + stride]); + } + } + Ok(()) + } + /// Write a raw on-disk unit (must be exactly `stride` bytes) for `page_id`. /// /// Past-EOF writes extend the file; intermediate units are zero-filled diff --git a/src/transaction/commit.rs b/src/transaction/commit.rs index 827a778..fc73dd7 100644 --- a/src/transaction/commit.rs +++ b/src/transaction/commit.rs @@ -159,7 +159,21 @@ pub(super) fn run_commit(ctx: &mut CommitCtx<'_>) -> Result<()> { // here can only damage the new superblock, never the N-1 // last-known-good ones. let inactive = *ctx.txn_counter % ctx.superblock_count as u64; - cache.io_mut().write_page(inactive, &buf)?; + // Superblock-at-8232: for an encrypted DB, stride is ENC_PAGE_SIZE=8232 so + // write_page (which asserts stride==PAGE_SIZE) would panic. The superblock + // image is 8192 bytes (plaintext header + Phase-2 encrypted body) and is NOT + // PageCipher-sealed; Phase 2 already encrypts its body. We write it into a + // zero-padded ENC_PAGE_SIZE unit so the slot occupies the same 8232-byte + // region on disk that data pages use, then write via write_page_unit. + // For plaintext DBs (stride==PAGE_SIZE), write_page still works fine. + if ctx.cipher.is_some() { + use crate::crypto::ENC_PAGE_SIZE; + let mut unit = [0u8; ENC_PAGE_SIZE]; + unit[..buf.len()].copy_from_slice(&buf); + cache.io_mut().write_page_unit(inactive, &unit)?; + } else { + cache.io_mut().write_page(inactive, &buf)?; + } // Step 4: Durability linearization point. Until this fsync returns the // transaction is not crash-safe; after it returns the new state is // observable on recovery. diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index e77166e..6ae95b5 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -216,11 +216,11 @@ pub struct TransactionManager { // lib.rs); there is no cross-thread access to synchronize against. poisoned: Cell, /// Per-session page cipher for an encrypted database. `None` for plaintext. - /// Holds the unwrapped DEK (zeroizing) for the life of the manager; reaches - /// the PageCache in Phase 3 for per-page seal/open. Set on the create path - /// (fresh DEK) and on the open path (DEK unwrapped from a key-slot). The DEK - /// inside PageCipher is zeroizing and is cleared on drop. - #[allow(dead_code)] // Phase 3 wires this to page I/O; commit path uses it via CommitCtx + /// Holds the unwrapped DEK (zeroizing) for the life of the manager; the + /// PageCache gets its own clone at open time (recovery.rs) for per-page + /// seal/open. The manager's copy is also threaded through CommitCtx for the + /// superblock body seal on every commit. The DEK inside PageCipher is + /// zeroizing and is cleared on drop. cipher: Option, /// The crypto-header (algorithm id + key-slot table) for an encrypted database. /// Written verbatim into every committed superblock. `None` for plaintext DBs. diff --git a/src/transaction/recovery.rs b/src/transaction/recovery.rs index f018094..7a04440 100644 --- a/src/transaction/recovery.rs +++ b/src/transaction/recovery.rs @@ -49,6 +49,23 @@ impl TransactionManager { Some(k) => Some(build_create_cipher(&k)?), }; + // Uniform-stride layout (encryption spec): an encrypted DB uses the + // ENC_PAGE_SIZE (8232) stride for EVERY page — superblock slots + // included — FROM BIRTH. Set the stride BEFORE writing the initial + // slots so the file is a clean multiple of 8232 (= total_pages * + // stride). Without this, fresh slots would be written at PAGE_SIZE and + // a never-committed encrypted DB's file length (N*8192) would not + // divide evenly by the open-time stride (8232), breaking the + // page_count == total_pages invariant that open_existing relies on. + // A superblock slot still holds the 8192-byte superblock image; the + // trailing 40 bytes of its 8232-byte unit stay zero. The cipher is + // installed on the cache AFTER this loop so data-page writes seal + // through PageCache::write_sealed (superblock images are NOT + // PageCipher-sealed — their body is sealed by serialize_encrypted). + if create_crypto.is_some() { + cache.io_mut().set_stride(crate::crypto::ENC_PAGE_SIZE); + } + // Write N staggered slots. Slot 0 gets the highest counter // (superblock_count - 1), slot N-1 gets 0. First user commit // bumps to N, which modulo N is 0, so slot 0 is the first to @@ -66,11 +83,22 @@ impl TransactionManager { }; for i in 0..superblock_count { sb.txn_counter = (superblock_count - 1 - i) as u64; - let buf = match &create_crypto { - None => sb.serialize(), - Some(cc) => sb.serialize_encrypted(&cc.page_cipher), - }; - cache.io_mut().write_page(i as u64, &buf)?; + match &create_crypto { + None => { + // Plaintext: stride == PAGE_SIZE, write_page is correct. + let buf = sb.serialize(); + cache.io_mut().write_page(i as u64, &buf)?; + } + Some(cc) => { + // Encrypted: zero-pad the 8192-byte image into an 8232-byte + // unit (trailing 40 bytes stay zero) and write at the now-set + // ENC_PAGE_SIZE stride. write_page would panic (stride assert). + let buf = sb.serialize_encrypted(&cc.page_cipher); + let mut unit = [0u8; crate::crypto::ENC_PAGE_SIZE]; + unit[..buf.len()].copy_from_slice(&buf); + cache.io_mut().write_page_unit(i as u64, &unit)?; + } + } } cache.io_mut().fsync()?; cache.set_next_page_id(superblock_count as u64); @@ -94,9 +122,21 @@ impl TransactionManager { }; // Split the CreateCrypto struct into its session parts before consuming. + // The stride was already set to ENC_PAGE_SIZE above (before the slot + // loop) for the uniform-stride layout. Here we only install the cipher + // on the cache so subsequent DATA-page writes seal through + // PageCache::write_sealed. Superblock images are NOT PageCipher-sealed + // (their body is sealed by serialize_encrypted), which is why the slot + // loop above wrote them directly rather than through the cache. let (session_cipher, session_header) = match create_crypto { None => (None, None), - Some(cc) => (Some(cc.page_cipher), Some(cc.header)), + Some(cc) => { + // Clone: both the session manager and the page cache need their + // own PageCipher instance. Both hold independent Zeroizing DEK + // copies and wipe independently on drop. + cache.set_cipher(cc.page_cipher.clone()); + (Some(cc.page_cipher), Some(cc.header)) + } }; Ok(TransactionManager { @@ -163,16 +203,91 @@ impl TransactionManager { mut cache: PageCache, key: Option, ) -> Result { - // Step 1: read up to MAX_SUPERBLOCKS pages as candidates. - let mut candidates: Vec<[u8; PAGE_SIZE]> = Vec::new(); - for i in 0..MAX_SUPERBLOCKS as u64 { - // If the file is shorter than MAX_SUPERBLOCKS (fresh DB - // with small N), read_page returns InvalidPageId (I16). - // Stop probing at EOF. - match cache.io_mut().read_page(i) { - Ok(buf) => candidates.push(buf), - Err(ChiselError::InvalidPageId { .. }) => break, - Err(e) => return Err(e), + // Step 1: read up to MAX_SUPERBLOCKS slots as candidates. + // + // Bootstrap stride (encryption spec): an encrypted DB uses a uniform + // ENC_PAGE_SIZE (8232) stride for EVERY page, superblock slots + // included, but we do not yet know whether THIS file is encrypted, and + // we must NOT assume page 0 is intact — the commit protocol overwrites + // slot `txn_counter % N`, so slot 0 IS a torn-write target, and R4 + // crash recovery must still find a valid sibling. Each candidate slot + // image must therefore be read at the file's true stride; reading an + // encrypted slot at the wrong (8192) stride lands mid-unit and yields + // garbage that would defeat sibling fallback. + // + // Probe the stride by scanning slots at the default PAGE_SIZE stride + // first: a plaintext DB (all slots at 8192) deserializes immediately, + // and an encrypted DB's page 0 lives at offset 0 so it deserializes at + // 8192 too (its 40-byte trailing padding is ignored by deserialize). If + // ANY slot deserializes with an encryption header, switch the IO stride + // to header.stride and RE-READ all candidate slots at that stride so + // siblings 1..N land on their true 8232-byte boundaries. The re-read is + // what preserves torn-slot-0 recovery for encrypted DBs: even if page 0 + // is torn, a sibling read at 8232 supplies the header that unlocks the + // correct stride. This switch also happens BEFORE the total_pages / + // file-size validation below, so page_count = file_len/stride == N. + let read_candidates = |cache: &mut PageCache| -> Result> { + let mut out: Vec<[u8; PAGE_SIZE]> = Vec::new(); + for i in 0..MAX_SUPERBLOCKS as u64 { + // read_page_unit honors the current stride and returns a + // `stride`-byte unit; take the first PAGE_SIZE bytes as the + // slot's superblock image (trailing encrypted padding ignored). + // A file shorter than MAX_SUPERBLOCKS stops the probe at EOF. + match cache.io_mut().read_page_unit(i) { + Ok(unit) => { + let mut buf = [0u8; PAGE_SIZE]; + buf.copy_from_slice(&unit[..PAGE_SIZE]); + out.push(buf); + } + Err(ChiselError::InvalidPageId { .. }) => break, + Err(e) => return Err(e), + } + } + Ok(out) + }; + // Anchor on page 0: it always lives at byte offset 0 regardless of + // stride, so read it at the default PAGE_SIZE stride (read_page reads + // bytes 0..8192 = the page-0 image; an encrypted slot's 40-byte padding + // is ignored by deserialize). An intact, encrypted page 0 tells us the + // stride directly via its cleartext crypto-header — the common case. + let page0 = cache.io_mut().read_page(0)?; + if let Some(stride) = Superblock::deserialize(&page0) + .and_then(|sb| sb.encryption.map(|h| h.stride as usize)) + { + cache.io_mut().set_stride(stride); + } + // With an intact page 0 the stride is already correct here, so this + // first read picks up every slot at its true boundary. The fallback + // below only fires when page 0 is torn (see its comment). + let mut candidates = read_candidates(&mut cache)?; + // Helper: the encrypted stride advertised by the first candidate that + // deserializes with an encryption header (None for plaintext/torn). + let encrypted_stride = |cands: &[[u8; PAGE_SIZE]]| -> Option { + cands + .iter() + .filter_map(Superblock::deserialize) + .find_map(|sb| sb.encryption.map(|h| h.stride as usize)) + }; + if encrypted_stride(&candidates).is_none() + && Superblock::deserialize(&page0).is_none() + { + // Torn-slot-0 recovery for encrypted DBs: when slot 0 is a torn + // write, page 0 fails to deserialize so the anchor above could not + // learn the stride, and the default-stride candidate scan finds + // nothing (siblings 1..N sit at 8232 offsets — misaligned garbage + // when read at 8192). Speculatively retry at the encrypted stride: + // every slot carries the cleartext crypto-header, so an intact + // sibling read at its true boundary supplies the stride and lets + // select() fall back to it. If this read ALSO yields no encryption + // header (a genuinely plaintext-but-torn file), restore the + // default-stride candidates so the CorruptSuperblock diagnosis + // below reflects the real bytes. + cache.io_mut().set_stride(crate::crypto::ENC_PAGE_SIZE); + let enc_candidates = read_candidates(&mut cache)?; + if encrypted_stride(&enc_candidates).is_some() { + candidates = enc_candidates; + } else { + cache.io_mut().set_stride(PAGE_SIZE); } } @@ -221,6 +336,12 @@ impl TransactionManager { // InvalidEncryptionKey rather than poisoning. sb.decrypt_body(&cipher, raw) .map_err(|_| ChiselError::InvalidEncryptionKey)?; + // The IO stride was already switched to header.stride during the + // bootstrap read above (so slots 1..N and the file-size checks + // use the 8232-byte unit). Here we only install the cipher on + // the cache so subsequent data-page reads go through + // PageCipher::open. + cache.set_cipher(cipher.clone()); Some(cipher) } }; @@ -292,20 +413,27 @@ impl TransactionManager { let page_count = cache.io_mut().page_count()?; if page_count < sb.total_pages { + // Stride-correct byte counts: encrypted DBs use ENC_PAGE_SIZE + // (8232) for every page including superblock slots, so multiply by + // the CURRENT stride, not the hardcoded PAGE_SIZE. Both page_count + // and total_pages are logical page counts under that uniform + // stride; reporting them as PAGE_SIZE bytes would understate an + // encrypted file's true size. + let stride = cache.io_mut().stride() as u64; return Err(ChiselError::FileSizeMismatch { // saturating_mul: `sb.total_pages` comes from a checksum-valid but // otherwise untrusted superblock — `Superblock::deserialize` bounds // only the checksum, MAGIC, and superblock_count, NOT total_pages. // A crafted/edited file with total_pages near u64::MAX would - // overflow `* PAGE_SIZE` here: a panic in debug builds (how CI + // overflow `* stride` here: a panic in debug builds (how CI // runs) and a silent wrap in release. Saturating keeps the public // `Chisel::open` a typed-error path; "as many bytes as a u64 can // represent" is the right report for an absurd page count (mirrors // the I47 saturation in `Chisel::stats`/`file_size_bytes`). // `page_count` is file-length-bounded and cannot realistically // overflow, but it is saturated too for symmetry. - expected: sb.total_pages.saturating_mul(PAGE_SIZE as u64), - actual: page_count.saturating_mul(PAGE_SIZE as u64), + expected: sb.total_pages.saturating_mul(stride), + actual: page_count.saturating_mul(stride), }); } // Reset next_page_id from the authoritative superblock, NOT from diff --git a/tests/encryption_open.rs b/tests/encryption_open.rs index 3c66ae5..31efe15 100644 --- a/tests/encryption_open.rs +++ b/tests/encryption_open.rs @@ -4,6 +4,7 @@ // error, spurious-key-on-plaintext error, and plaintext-DB regression. use chisel::{Chisel, Key, Options}; +use std::io::{Seek, SeekFrom, Write}; use zeroize::Zeroizing; fn raw_key(b: u8) -> Key { @@ -221,3 +222,139 @@ fn named_root_round_trips_through_encrypted_open() { assert_eq!(db.read(h.unwrap()).unwrap(), b"payload"); } } + +/// Uniform-stride regression (N>2, never committed): with the encryption spec's +/// uniform 8232 stride, a 4-superblock encrypted DB's file must be exactly +/// 4 * 8232 bytes from birth so page_count == total_pages == 4 on reopen. Under +/// the old PAGE_SIZE-stride-then-switch layout the file was 4 * 8192, which is +/// not a multiple of 8232, so the open-time page_count (file_len/8232 = 3 < 4) +/// would spuriously raise FileSizeMismatch. N=4 (not the default N=2) is chosen +/// because integer-division masked the N=2 case at exactly one boundary. +#[test] +fn never_committed_encrypted_db_with_extra_superblocks_reopens() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("n4.chisel"); + { + let _db = Chisel::open( + &path, + Options::default() + .with_encryption_key(raw_key(0x55)) + .superblock_count(4), + ) + .unwrap(); + // Drop with no begin/commit — exercise the create-only file layout. + } + let reopened = Chisel::open( + &path, + Options::default() + .with_encryption_key(raw_key(0x55)) + .create_if_missing(false), + ); + assert!( + reopened.is_ok(), + "never-committed N=4 encrypted DB must reopen; got: {:?}", + reopened.err() + ); +} + +/// Uniform-stride regression (multi-page, committed): allocate a value large +/// enough to force overflow data pages, commit, reopen, and read it back. This +/// drives the cold-load seal/open path across several data pages AND confirms +/// the committed file (superblock slots + data pages, all at 8232 stride) stays +/// a clean multiple of the stride so reopen's total_pages check passes. +#[test] +fn multi_page_encrypted_value_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("big.chisel"); + // 32 KiB payload spans multiple 8 KiB pages via the overflow chain. + let payload: Vec = (0..32 * 1024).map(|i| (i % 251) as u8).collect(); + let handle; + { + let mut db = Chisel::open( + &path, + Options::default().with_encryption_key(raw_key(0x77)), + ) + .unwrap(); + db.begin().unwrap(); + handle = db.allocate(&payload).unwrap(); + db.commit().unwrap(); + } + { + let db = Chisel::open( + &path, + Options::default() + .with_encryption_key(raw_key(0x77)) + .create_if_missing(false), + ) + .unwrap(); + assert_eq!( + db.read(handle).unwrap(), + payload, + "multi-page encrypted value must survive close+reopen byte-for-byte" + ); + } +} + +/// R4 crash-recovery for encrypted DBs: a torn write to slot 0 must still open +/// via a valid sibling slot. This exercises the uniform-stride bootstrap's +/// torn-slot-0 fallback: page 0 fails to deserialize, so open_existing cannot +/// learn the stride from it and must speculatively retry at the encrypted +/// stride to read sibling slots (which sit at their true 8232-byte offsets) and +/// recover the correct DEK + state. Without that fallback the correct key would +/// spuriously fail to open a recoverable file. +#[test] +fn torn_slot_0_encrypted_db_recovers_via_sibling() { + // ENC_PAGE_SIZE is not part of the public API; encode the on-disk slot + // stride locally. A mismatch would make the seek miss slot 0 and the test + // would (correctly) fail loudly rather than silently pass. + const ENC_STRIDE: u64 = 8232; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("torn.chisel"); + let h1; + { + let mut db = Chisel::open( + &path, + Options::default().with_encryption_key(raw_key(0x99)), + ) + .unwrap(); + // Two commits so BOTH slots (N=2) hold valid post-commit superblocks: + // commit 1 → slot 0, commit 2 → slot 1. After corrupting slot 0, + // recovery must fall back to slot 1. + db.begin().unwrap(); + h1 = db.allocate(b"survives the tear").unwrap(); + db.commit().unwrap(); + db.begin().unwrap(); + let _h2 = db.allocate(b"second commit").unwrap(); + db.commit().unwrap(); + } + // Simulate a torn write to slot 0: zero its first PAGE_SIZE bytes so the + // page-0 image fails to deserialize (anchor cannot learn the stride). + { + let mut f = std::fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap(); + f.seek(SeekFrom::Start(0)).unwrap(); + f.write_all(&[0u8; 8192]).unwrap(); + f.sync_all().unwrap(); + } + // The file must still be a clean multiple of the encrypted stride (the + // corruption only overwrote bytes, did not change the length). + let len = std::fs::metadata(&path).unwrap().len(); + assert_eq!( + len % ENC_STRIDE, + 0, + "encrypted file length {len} must stay a multiple of {ENC_STRIDE}" + ); + // Recovery must open via the intact sibling slot 1 and expose commit-2 + // state, which still resolves the commit-1 handle h1. + let db = Chisel::open( + &path, + Options::default() + .with_encryption_key(raw_key(0x99)) + .create_if_missing(false), + ) + .expect("correct key must recover a torn-slot-0 encrypted DB via its sibling"); + assert_eq!(db.read(h1).unwrap(), b"survives the tear"); +} From c66479b05ddab519eedfc12711b816f344e162e1 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 11:39:25 -0700 Subject: [PATCH 23/42] feat(page_cache): seal-once on evict-to-spillway; verbatim copy on drain 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 --- src/page_cache.rs | 314 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 293 insertions(+), 21 deletions(-) diff --git a/src/page_cache.rs b/src/page_cache.rs index 3dbacf9..7146660 100644 --- a/src/page_cache.rs +++ b/src/page_cache.rs @@ -486,19 +486,46 @@ impl PageCache { // effectively rolled back. If commit is ever made retryable, // reorder to write_page-then-forget so a failed drain leaves the // page recoverable from the spillway (review 2026-06-22). + // Drain: the spillway slot holds either a sealed 8232-byte + // ciphertext blob (encrypted DB) or a plaintext 8192-byte + // page image (plaintext DB). In both cases we copy it VERBATIM + // to the main file via write_page_unit — never re-seal. + // + // For the cache re-insertion we need the plaintext page, so: + // - Encrypted: open the sealed blob → plaintext. + // - Plaintext: the blob IS the plaintext. + // + // Seal-once invariant: the page was sealed exactly once, at + // eviction in maybe_evict Phase B. A second seal here would + // produce a new nonce and corrupt the on-disk unit. let plaintext: Box<[u8; page::PAGE_SIZE]> = { let spw = self.spillway.as_mut().unwrap(); - let b = spw.rehydrate(page_id)?; + let blob = spw.rehydrate(page_id)?; spw.forget(page_id); - // rehydrate returns Vec; for a plaintext spillway the - // payload is always PAGE_SIZE bytes. Task 3.4 replaces this - // with the sealed-blob path (spillway will hold ciphertext); - // until then, the spillway always holds plaintext. - Box::new(b.try_into().expect("spillway rehydrate returned wrong length")) + match &self.cipher { + Some(c) => { + // blob is ENC_PAGE_SIZE bytes (sealed at eviction). + // Copy verbatim to the main file, then open for cache. + let unit: [u8; ENC_PAGE_SIZE] = blob + .as_slice() + .try_into() + .expect("spillway rehydrate returned wrong length for encrypted blob"); + self.io.write_page_unit(page_id, &unit)?; + let pt = c + .open(page_id, &unit) + .map_err(|_| ChiselError::DecryptionFailed { page_id })?; + Box::new(pt) + } + None => { + // blob is PAGE_SIZE bytes; write directly. + let pt: [u8; page::PAGE_SIZE] = blob + .try_into() + .expect("spillway rehydrate returned wrong length for plaintext blob"); + self.io.write_page_unit(page_id, &pt)?; + Box::new(pt) + } + } }; - // write_sealed handles both plaintext and encrypted stride; it - // must not be write_page (which asserts stride == PAGE_SIZE). - self.write_sealed(page_id, &plaintext)?; // Re-insert as clean: the bytes are now on the main file, // so the cache entry is a valid read-through cache. let entry = CacheEntry { @@ -868,8 +895,9 @@ impl PageCache { /// For a plaintext DB: calls `write_page_unit` directly with the 8192-byte /// image (stride == PAGE_SIZE, so the unit is the page image itself). /// - /// This is the single seal point shared by flush Phase 1a. Task 3.4 will - /// add the evict-to-spillway call here as well. + /// This is the seal point for flush Phase 1a (in-cache dirty pages → + /// main file). Evict-to-spillway seals independently in maybe_evict + /// Phase B (seal-once invariant: each page is sealed exactly once). fn write_sealed(&mut self, page_id: u64, plaintext: &[u8; PAGE_SIZE]) -> Result<()> { match &self.cipher { Some(c) => { @@ -920,12 +948,27 @@ impl PageCache { // pre-transaction bytes. if let Some(spw) = self.spillway.as_mut() { if spw.is_resident(page_id) { - let buf: Box<[u8; page::PAGE_SIZE]> = { - let b = spw.rehydrate(page_id)?; - spw.forget(page_id); - // rehydrate returns Vec; for plaintext always PAGE_SIZE. - // Task 3.3/3.4 replaces with the sealed-blob path. - Box::new(b.try_into().expect("spillway rehydrate returned wrong length")) + // Rehydrate: the spillway holds either a sealed blob (encrypted DB) + // or raw plaintext (plaintext DB). Open ciphertext → plaintext + // before re-inserting into the cache. The page is still dirty + // (it was dirty when it was evicted to the spillway). + let blob = spw.rehydrate(page_id)?; + spw.forget(page_id); + let buf: Box<[u8; page::PAGE_SIZE]> = match &self.cipher { + Some(c) => { + let unit: [u8; ENC_PAGE_SIZE] = blob + .as_slice() + .try_into() + .expect("spillway rehydrate returned wrong length for encrypted blob"); + let pt = c + .open(page_id, &unit) + .map_err(|_| ChiselError::DecryptionFailed { page_id })?; + Box::new(pt) + } + None => Box::new( + blob.try_into() + .expect("spillway rehydrate returned wrong length for plaintext blob"), + ), }; self.entries.insert( page_id, @@ -1122,9 +1165,19 @@ impl PageCache { // so `&entry.buf` does not conflict with the `&mut self` borrow that // `ensure_spillway` takes. An `ensure_spillway()` open error (I/O) // is restored the same way. + // Seal-once invariant (Task 3.4): for an encrypted DB, seal the + // plaintext page here before handing the bytes to the spillway. + // The spillway then holds ciphertext for the lifetime of the txn. + // Drain will copy that ciphertext verbatim to the main file (no + // second seal). Rehydrate (load_page spillway branch) will open it. + // For plaintext DBs the page bytes are stored as-is (no cipher). + let spill_blob: Vec = match &self.cipher { + Some(c) => c.seal(victim_id, entry.buf.as_ref()).to_vec(), + None => entry.buf.as_ref().to_vec(), + }; let spill_result = self .ensure_spillway() - .and_then(|spw| spw.spill(victim_id, entry.buf.as_ref())); + .and_then(|spw| spw.spill(victim_id, &spill_blob)); if let Err(e) = spill_result { if entry.dirty { self.dirty_count += 1; @@ -1162,9 +1215,15 @@ impl PageCache { // otherwise create a sidecar file for what is supposed to be a // read-only open. if self.spillway.is_none() { - // ponytail: spillway holds plaintext until Task 3.4 adds seal-on-evict; - // payload_size is PAGE_SIZE for both plaintext and (current) encrypted DBs. - let payload_size = page::PAGE_SIZE; + // Encrypted DBs store the sealed 8232-byte unit in each slot so the + // spillway NEVER holds plaintext (seal-once invariant, Task 3.4). + // Plaintext DBs use the historical 8192-byte payload; slot layout is + // SLOT_HEADER_SIZE + payload_size in both cases. + let payload_size = if self.cipher.is_some() { + ENC_PAGE_SIZE + } else { + page::PAGE_SIZE + }; let spw = match &self.spillway_location { crate::SpillwayLocation::Path(p) => { crate::spillway::Spillway::open_file(p, self.spillway_max_bytes, payload_size)? @@ -1893,6 +1952,219 @@ mod tests { assert_eq!(read[4096], 0xC9, "mid-page byte must survive seal/open"); } + // ----------------------------------------------------------------------- + // Encrypted spillway tests (Task 3.4) + // ----------------------------------------------------------------------- + + /// Encrypted cache + spillway helper: stride=ENC_PAGE_SIZE, cipher installed, + /// spillway enabled (InMemory for filesystem independence). `max_pages` is + /// the strict cache cap; `spillway_pages` is the spillway capacity in pages + /// (each spilled slot is ENC_PAGE_SIZE bytes, so spillway_max_bytes reflects that). + fn fresh_encrypted_cache_with_spillway( + max_pages: usize, + spillway_pages: usize, + ) -> (TempDir, PageCache) { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("test.chisel"); + let mut io = PageIo::open(&db_path, false).unwrap(); + io.set_stride(ENC_PAGE_SIZE); + let cache_max_bytes = max_pages as u64 * PAGE_SIZE as u64; + // Spillway slots hold ENC_PAGE_SIZE bytes each for encrypted DBs. + let spillway_max_bytes = + (spillway_pages as u64) * (crate::spillway::SLOT_HEADER_SIZE + ENC_PAGE_SIZE) as u64; + let mut cache = PageCache::new( + io, + cache_max_bytes, + spillway_max_bytes, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + cache.set_cipher(PageCipher::new(random_dek())); + (dir, cache) + } + + /// Spilling an encrypted page must store ciphertext in the spillway slot, + /// not plaintext. We write a known sentinel byte to page A, force it to + /// spill by allocating enough pages to overflow the cache, then read the + /// raw spillway slot bytes and assert the sentinel is NOT present verbatim. + #[test] + fn encrypted_spill_stores_ciphertext_not_plaintext() { + // 2-page cache; spillway fits 4 encrypted pages. + let (_dir, mut cache) = fresh_encrypted_cache_with_spillway(2, 4); + + let id_a = cache.new_page().unwrap(); + { + let buf = cache.get_mut(id_a).unwrap(); + // Distinctive 8-byte sentinel at offset 0 (before checksum region). + buf[..8].copy_from_slice(b"SENTINEL"); + page::stamp_checksum(buf); + } + + // Force id_a to spill: two more allocations overflow the 2-page cache. + cache.new_page().unwrap(); + cache.new_page().unwrap(); + + // id_a is now in the spillway, not the cache. + assert!( + !cache.entries.contains_key(&id_a), + "page A should have spilled out of the cache" + ); + { + let spw = cache.spillway.as_ref().unwrap(); + assert!(spw.is_resident(id_a), "page A must be in the spillway"); + } + // Read the spillway slot bytes and confirm the plaintext sentinel is absent. + // `rehydrate` returns the stored blob (verifying the slot checksum); for an + // encrypted DB that blob is XChaCha20-Poly1305 ciphertext — the sentinel + // must NOT appear verbatim anywhere in it. + let blob = cache.spillway.as_mut().unwrap().rehydrate(id_a).unwrap(); + let sentinel_pos = blob + .windows(8) + .position(|w| w == b"SENTINEL"); + assert!( + sentinel_pos.is_none(), + "plaintext sentinel found verbatim in spillway slot — spill did not encrypt" + ); + } + + /// After spilling encrypted pages and flushing (drain → main file), a cold + /// read of each spilled page must return the correct plaintext. + #[test] + fn encrypted_spill_drain_and_cold_read_round_trips() { + // 2-page cache; spillway for 4 encrypted pages. Allocate 4 total: 2 in + // cache, 2 spilled. Flush drains to the main file; then evict all and + // cold-read all four back. + let (_dir, mut cache) = fresh_encrypted_cache_with_spillway(2, 4); + + let mut ids = Vec::new(); + for n in 0..4u8 { + let pid = cache.new_page().unwrap(); + { + let buf = cache.get_mut(pid).unwrap(); + buf[0] = 0x40 | n; // distinct sentinel per page + page::stamp_checksum(buf); + } + ids.push(pid); + } + // Drain spilled pages to main file and write remaining in-cache dirty pages. + cache.flush().unwrap(); + + // Evict all entries so every subsequent get() is a cold disk read. + for &pid in &ids { + cache.test_drop_from_cache(pid); + } + + // Cold read: each page must return its plaintext sentinel. + for (n, &pid) in ids.iter().enumerate() { + let buf = cache.get(pid).unwrap(); + assert_eq!( + buf[0], + 0x40 | n as u8, + "page {pid} cold-read returned wrong byte after encrypted spill+drain" + ); + } + } + + /// Rehydrate path: reading a page that is STILL resident in the spillway + /// (not yet drained) must return the correct plaintext, NOT disk content. + #[test] + fn encrypted_rehydrate_returns_correct_plaintext() { + let (_dir, mut cache) = fresh_encrypted_cache_with_spillway(2, 4); + + let id_a = cache.new_page().unwrap(); + { + let buf = cache.get_mut(id_a).unwrap(); + buf[0] = 0xEE; + page::stamp_checksum(buf); + } + + // Force id_a to spill. + cache.new_page().unwrap(); + cache.new_page().unwrap(); + + assert!( + !cache.entries.contains_key(&id_a), + "precondition: id_a must be spilled, not cached" + ); + assert!(cache.spillway.as_ref().unwrap().is_resident(id_a)); + + // Reading id_a rehydrates from the spillway — must not see disk zeros. + let buf = cache.get(id_a).unwrap(); + assert_eq!( + buf[0], 0xEE, + "rehydrated encrypted page must return in-flight plaintext" + ); + } + + /// Full end-to-end: encrypted DB, several pages force spill, commit + /// (flush drains), DB is reopened (simulated by cold-evicting all entries), + /// all pages cold-read back correctly. + #[test] + fn encrypted_spill_full_round_trip_multiple_pages() { + let (_dir, mut cache) = fresh_encrypted_cache_with_spillway(2, 8); + + let mut ids = Vec::new(); + for n in 0..6u8 { + let pid = cache.new_page().unwrap(); + { + let buf = cache.get_mut(pid).unwrap(); + buf[0] = n; + buf[100] = n.wrapping_mul(7); + page::stamp_checksum(buf); + } + ids.push(pid); + } + + cache.flush().unwrap(); + + for &pid in &ids { + cache.test_drop_from_cache(pid); + } + + for (n, &pid) in ids.iter().enumerate() { + let buf = cache.get(pid).unwrap(); + assert_eq!(buf[0], n as u8, "page {pid} byte[0] mismatch after full round trip"); + assert_eq!( + buf[100], + (n as u8).wrapping_mul(7), + "page {pid} byte[100] mismatch after full round trip" + ); + } + } + + /// Plaintext spill/drain must be byte-identical to before Task 3.4 — + /// no cipher, slot is PAGE_SIZE, drain goes through write_page_unit. + #[test] + fn plaintext_spill_drain_unchanged() { + let max_pages = 2; + let spillway_bytes = 4 * PAGE_SIZE as u64; + let (_dir, mut cache) = fresh_cache_with_spillway(max_pages, spillway_bytes); + + let mut ids = Vec::new(); + for n in 0..4u8 { + let pid = cache.new_page().unwrap(); + { + let buf = cache.get_mut(pid).unwrap(); + buf[0] = 0xB0 | n; + page::stamp_checksum(buf); + } + ids.push(pid); + } + cache.flush().unwrap(); + + for &pid in &ids { + cache.test_drop_from_cache(pid); + } + for (n, &pid) in ids.iter().enumerate() { + let buf = cache.get(pid).unwrap(); + assert_eq!( + buf[0], + 0xB0 | n as u8, + "plaintext spill/drain page {pid} byte mismatch" + ); + } + } + /// Tampering with a ciphertext byte must surface DecryptionFailed (AEAD /// authentication failure). This proves the AEAD tag is verified on open. #[test] From 63b03e5d9bc545510943cf0db12b879b872417c3 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 12:15:16 -0700 Subject: [PATCH 24/42] feat(api): expose Key/Argon2Params publicly, add argon2_params to Options - 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 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 --- src/defrag.rs | 2 +- src/lib.rs | 65 ++++++++++++++++++++++++++++++++----- src/transaction/recovery.rs | 19 ++++++++--- src/transaction/tests.rs | 20 ++++++------ tests/encryption_create.rs | 12 +++---- tests/encryption_open.rs | 38 +++++++++++----------- tests/public_key_api.rs | 37 +++++++++++++++++++++ 7 files changed, 143 insertions(+), 50 deletions(-) create mode 100644 tests/public_key_api.rs diff --git a/src/defrag.rs b/src/defrag.rs index c4447d8..53bbbcf 100644 --- a/src/defrag.rs +++ b/src/defrag.rs @@ -286,7 +286,7 @@ mod tests { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut tm = TransactionManager::create_new(cache, 2, None).unwrap(); + let mut tm = TransactionManager::create_new(cache, 2, None, None).unwrap(); tm.begin().unwrap(); tm.commit().unwrap(); tm diff --git a/src/lib.rs b/src/lib.rs index 74aea56..c276cce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -144,9 +144,19 @@ pub struct Options { pub create_if_missing: bool, pub read_only: bool, pub superblock_count: u32, - /// Encryption key supplied at open/create. `Some` creates (or opens) an - /// encrypted database; `None` keeps the existing plaintext format. - pub(crate) encryption_key: Option, + /// Encryption key for an encrypted database. `None` (default) opens or + /// creates a plaintext DB. On create, `Some(key)` makes a new encrypted + /// DB sealed under a random DEK wrapped by this key. On reopen, the key + /// must unwrap one of the on-disk key slots or `open` returns + /// `InvalidEncryptionKey`. Supplying a key to open a plaintext DB returns + /// `EncryptionNotSupported`; omitting it on an encrypted DB returns + /// `NoEncryptionKey`. + pub encryption_key: Option, + /// Argon2id cost parameters used to derive the KEK from a `Key::Passphrase` + /// on *create*. `None` uses `Argon2Params::default()` (OWASP: 19 MiB / t=2 / + /// p=1). Ignored for `Key::Raw` (HKDF, no cost params) and on reopen (the + /// params are read from the key slot the file was written with). + pub argon2_params: Option, } /// Where commit-drain rehydrated pages are inserted into the LRU. @@ -198,6 +208,7 @@ impl Default for Options { read_only: false, superblock_count: superblock::DEFAULT_SUPERBLOCK_COUNT, encryption_key: None, + argon2_params: None, } } } @@ -246,14 +257,22 @@ impl Options { self } - /// Supply an encryption key. On create, a fresh DEK is generated and + /// Set the encryption key. On create, a fresh DEK is generated and /// wrapped into key-slot 0 under a KEK derived from this key; the - /// superblock is stamped MAJOR=2. On open (Task 2.4), the key is used - /// to unwrap the stored DEK from the matching slot. - pub fn with_encryption_key(mut self, key: crate::crypto::Key) -> Self { + /// superblock is stamped MAJOR=2. On open, the key is used to unwrap + /// the stored DEK from the matching slot. See [`Options::encryption_key`] + /// for the full create-vs-reopen semantics. + pub fn encryption_key(mut self, key: Key) -> Self { self.encryption_key = Some(key); self } + /// Set the Argon2id cost parameters used when deriving a KEK from a + /// passphrase on database creation. No effect for raw keys or on reopen + /// (the stored slot carries its own params). See [`Options::argon2_params`]. + pub fn argon2_params(mut self, params: Argon2Params) -> Self { + self.argon2_params = Some(params); + self + } } /// A live handle to an open Chisel database. @@ -367,6 +386,7 @@ impl Chisel { cache, options.superblock_count, options.encryption_key.clone(), + options.argon2_params, )? }; @@ -425,8 +445,8 @@ impl Chisel { options.drain_insertion, SpillwayLocation::InMemory, ); - // ponytail: in-memory path never encrypts (no key supplied at this call site) - let txm = TransactionManager::create_new(cache, options.superblock_count, None)?; + // ponytail: in-memory databases never use encryption; key and argon2_params are ignored here + let txm = TransactionManager::create_new(cache, options.superblock_count, None, None)?; Ok(Chisel { txm }) } @@ -916,3 +936,30 @@ impl Chisel { self.txm.set_drain_insertion(policy) } } + +#[cfg(test)] +mod options_encryption_tests { + use super::*; + + // The two encryption fields default to None (a plaintext DB) and round-trip + // through the chained-setter builder, preserving #[non_exhaustive] (callers + // can't struct-literal, so the setters are the only construction path). + #[test] + fn encryption_options_default_none_and_set() { + let o = Options::default(); + assert!(o.encryption_key.is_none()); + assert!(o.argon2_params.is_none()); + + let raw = Key::Raw(zeroize::Zeroizing::new(vec![0u8; 32])); + let o = Options::default() + .encryption_key(raw) + .argon2_params(Argon2Params { + m_cost: 19456, + t_cost: 2, + p_cost: 1, + }); + assert!(matches!(o.encryption_key, Some(Key::Raw(_)))); + let p = o.argon2_params.expect("set above"); + assert_eq!((p.m_cost, p.t_cost, p.p_cost), (19456, 2, 1)); + } +} diff --git a/src/transaction/recovery.rs b/src/transaction/recovery.rs index 7a04440..809eadd 100644 --- a/src/transaction/recovery.rs +++ b/src/transaction/recovery.rs @@ -33,6 +33,7 @@ impl TransactionManager { mut cache: PageCache, superblock_count: u32, key: Option, + argon2_params: Option, ) -> Result { // Caller is expected to have validated bounds via Options in // lib.rs, but defend against direct-call misuse too. @@ -46,7 +47,7 @@ impl TransactionManager { // For plaintext DBs this is None and the slot-write loop uses serialize(). let create_crypto = match key { None => None, - Some(k) => Some(build_create_cipher(&k)?), + Some(k) => Some(build_create_cipher(&k, argon2_params)?), }; // Uniform-stride layout (encryption spec): an encrypted DB uses the @@ -557,7 +558,10 @@ struct CreateCrypto { /// The AAD passed to `wrap_dek` is `slot.aad()` — the same bytes that Task 2.4 /// reconstructs at unwrap time from the persisted slot fields. Keeping the AAD /// construction in one place (`KeySlot::aad`) ensures wrap and unwrap agree. -fn build_create_cipher(key: &crate::crypto::Key) -> Result { +fn build_create_cipher( + key: &crate::crypto::Key, + argon2_override: Option, +) -> Result { use crate::crypto::{ derive_kek, random_array, random_dek, wrap_dek, Argon2Params, KdfId, NONCE_LEN, SALT_LEN, }; @@ -567,11 +571,16 @@ fn build_create_cipher(key: &crate::crypto::Key) -> Result { let salt: [u8; SALT_LEN] = random_array(); let wrap_nonce: [u8; NONCE_LEN] = random_array(); - // KDF choice: a Raw key uses HKDF (fast, key-material quality); - // a Passphrase uses Argon2id (memory-hard, brute-force resistant). + // KDF choice: Raw → HKDF (fast, key-material quality); Passphrase → Argon2id + // (memory-hard, brute-force resistant). The argon2_override is the + // caller-supplied cost params from Options::argon2_params; falls back to the + // OWASP baseline default. Raw keys use HKDF regardless, so the override only + // has effect for Passphrase. let (kdf, params) = match key { crate::crypto::Key::Raw(_) => (KdfId::Hkdf, Argon2Params::default()), - crate::crypto::Key::Passphrase(_) => (KdfId::Argon2id, Argon2Params::default()), + crate::crypto::Key::Passphrase(_) => { + (KdfId::Argon2id, argon2_override.unwrap_or_default()) + } }; let kek = derive_kek(key, kdf, &salt, ¶ms)?; diff --git a/src/transaction/tests.rs b/src/transaction/tests.rs index dbc38db..d211e17 100644 --- a/src/transaction/tests.rs +++ b/src/transaction/tests.rs @@ -23,7 +23,7 @@ fn fresh_manager() -> TransactionManager { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut tm = TransactionManager::create_new(cache, 2, None).unwrap(); + let mut tm = TransactionManager::create_new(cache, 2, None, None).unwrap(); // Commit once so there's a real baseline to read/write against. tm.begin().unwrap(); tm.commit().unwrap(); @@ -261,7 +261,7 @@ fn fatal_error_outside_commit_also_poisons() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut tm = TransactionManager::create_new(cache, 2, None).unwrap(); + let mut tm = TransactionManager::create_new(cache, 2, None, None).unwrap(); tm.begin().unwrap(); h = tm.allocate(b"durable").unwrap(); tm.commit().unwrap(); @@ -1270,7 +1270,7 @@ fn commit_does_not_poison_when_cache_is_at_strict_cap() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut tm = TransactionManager::create_new(cache, 2, None).unwrap(); + let mut tm = TransactionManager::create_new(cache, 2, None, None).unwrap(); tm.begin().unwrap(); tm.commit().unwrap(); @@ -1532,7 +1532,7 @@ fn delete_membership_failure_survives_reopen_consistently() { crate::SpillwayLocation::InMemory, ); if create { - TransactionManager::create_new(cache, 2, None).unwrap() + TransactionManager::create_new(cache, 2, None, None).unwrap() } else { TransactionManager::open_existing(cache, None).unwrap() } @@ -1810,7 +1810,7 @@ fn allocate_membership_failure_survives_reopen_consistently() { crate::SpillwayLocation::InMemory, ); if create { - TransactionManager::create_new(cache, 2, None).unwrap() + TransactionManager::create_new(cache, 2, None, None).unwrap() } else { TransactionManager::open_existing(cache, None).unwrap() } @@ -2051,7 +2051,7 @@ fn format_version_gate_is_major_only() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let _ = TransactionManager::create_new(cache, 2, None).unwrap(); + let _ = TransactionManager::create_new(cache, 2, None, None).unwrap(); // drop() releases the flock so the test can read+write the // file directly below. } @@ -2137,7 +2137,7 @@ fn file_minor_newer_than_binary_is_forced_read_only() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let _ = TransactionManager::create_new(cache, 2, None).unwrap(); + let _ = TransactionManager::create_new(cache, 2, None, None).unwrap(); } // Patch every slot to (current MAJOR, MINOR + 1) and re-stamp checksums. @@ -2300,7 +2300,7 @@ fn reopen_preserves_committed_data() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut txm = TransactionManager::create_new(cache, 2, None).unwrap(); + let mut txm = TransactionManager::create_new(cache, 2, None, None).unwrap(); txm.begin().unwrap(); handle = txm.allocate(b"persistent").unwrap(); txm.commit().unwrap(); @@ -2493,7 +2493,7 @@ fn encrypted_manager_holds_session_cipher() { crate::SpillwayLocation::InMemory, ); let key = crate::crypto::Key::Raw(zeroize::Zeroizing::new(vec![0x5Au8; 32])); - let txm = TransactionManager::create_new(cache, 2, Some(key)).unwrap(); + let txm = TransactionManager::create_new(cache, 2, Some(key), None).unwrap(); assert!( txm.cipher.is_some(), "encrypted create must retain a session cipher" @@ -2513,7 +2513,7 @@ fn plaintext_manager_has_no_cipher() { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let txm = TransactionManager::create_new(cache, 2, None).unwrap(); + let txm = TransactionManager::create_new(cache, 2, None, None).unwrap(); assert!( txm.cipher.is_none(), "plaintext create must have no session cipher" diff --git a/tests/encryption_create.rs b/tests/encryption_create.rs index 33a2806..8b5e86c 100644 --- a/tests/encryption_create.rs +++ b/tests/encryption_create.rs @@ -62,7 +62,7 @@ fn create_encrypted_db_stamps_major_2() { let key = Key::Raw(Zeroizing::new(vec![0xAB_u8; 32])); let db = chisel::Chisel::open( tmp.path(), - Options::default().with_encryption_key(key), + Options::default().encryption_key(key), ) .expect("create encrypted db"); drop(db); @@ -83,7 +83,7 @@ fn create_encrypted_db_passphrase_stamps_major_2() { let key = Key::Passphrase(Zeroizing::new("hunter2".to_string())); let db = chisel::Chisel::open( tmp.path(), - Options::default().with_encryption_key(key), + Options::default().encryption_key(key), ) .expect("create encrypted db passphrase"); drop(db); @@ -101,7 +101,7 @@ fn create_encrypted_db_populates_slot_0_only() { let key = Key::Raw(Zeroizing::new(vec![0x77_u8; 32])); let db = chisel::Chisel::open( tmp.path(), - Options::default().with_encryption_key(key), + Options::default().encryption_key(key), ) .expect("create"); drop(db); @@ -150,7 +150,7 @@ fn create_encrypted_db_sealed_body_is_present() { let key = Key::Raw(Zeroizing::new(vec![0xCC_u8; 32])); let db = chisel::Chisel::open( tmp.path(), - Options::default().with_encryption_key(key), + Options::default().encryption_key(key), ) .expect("create"); drop(db); @@ -179,7 +179,7 @@ fn slot0_dek_unwraps_with_correct_key() { let key = Key::Raw(Zeroizing::new(vec![0x5A_u8; 32])); let db = chisel::Chisel::open( tmp.path(), - Options::default().with_encryption_key(key.clone()), + Options::default().encryption_key(key.clone()), ) .expect("create"); drop(db); @@ -225,7 +225,7 @@ fn slot0_dek_unwrap_fails_with_wrong_key() { let key = Key::Raw(Zeroizing::new(vec![0x5A_u8; 32])); let db = chisel::Chisel::open( tmp.path(), - Options::default().with_encryption_key(key), + Options::default().encryption_key(key), ) .expect("create"); drop(db); diff --git a/tests/encryption_open.rs b/tests/encryption_open.rs index 31efe15..eea2b0a 100644 --- a/tests/encryption_open.rs +++ b/tests/encryption_open.rs @@ -21,7 +21,7 @@ fn round_trip_open_with_correct_key() { { let mut db = Chisel::open( &path, - Options::default().with_encryption_key(raw_key(0x11)), + Options::default().encryption_key(raw_key(0x11)), ) .unwrap(); db.begin().unwrap(); @@ -33,7 +33,7 @@ fn round_trip_open_with_correct_key() { let db = Chisel::open( &path, Options::default() - .with_encryption_key(raw_key(0x11)) + .encryption_key(raw_key(0x11)) .create_if_missing(false), ) .unwrap(); @@ -50,7 +50,7 @@ fn wrong_key_is_operational_error_not_panic() { { let mut db = Chisel::open( &path, - Options::default().with_encryption_key(raw_key(0x11)), + Options::default().encryption_key(raw_key(0x11)), ) .unwrap(); db.begin().unwrap(); @@ -60,7 +60,7 @@ fn wrong_key_is_operational_error_not_panic() { let err = Chisel::open( &path, Options::default() - .with_encryption_key(raw_key(0x22)) + .encryption_key(raw_key(0x22)) .create_if_missing(false), ); assert!(err.is_err(), "wrong key must fail to open"); @@ -69,7 +69,7 @@ fn wrong_key_is_operational_error_not_panic() { let ok = Chisel::open( &path, Options::default() - .with_encryption_key(raw_key(0x11)) + .encryption_key(raw_key(0x11)) .create_if_missing(false), ); assert!(ok.is_ok(), "correct key must succeed after a wrong-key attempt"); @@ -83,7 +83,7 @@ fn missing_key_on_encrypted_db_errors() { { let mut db = Chisel::open( &path, - Options::default().with_encryption_key(raw_key(0x11)), + Options::default().encryption_key(raw_key(0x11)), ) .unwrap(); db.begin().unwrap(); @@ -106,7 +106,7 @@ fn key_supplied_for_plaintext_db_errors() { let err = Chisel::open( &path, Options::default() - .with_encryption_key(raw_key(0x11)) + .encryption_key(raw_key(0x11)) .create_if_missing(false), ); assert!(err.is_err(), "supplying a key to a plaintext DB must fail"); @@ -141,7 +141,7 @@ fn passphrase_key_round_trip() { let handle; { let mut db = - Chisel::open(&path, Options::default().with_encryption_key(pass())).unwrap(); + Chisel::open(&path, Options::default().encryption_key(pass())).unwrap(); db.begin().unwrap(); handle = db.allocate(b"secret").unwrap(); db.commit().unwrap(); @@ -150,7 +150,7 @@ fn passphrase_key_round_trip() { let db = Chisel::open( &path, Options::default() - .with_encryption_key(pass()) + .encryption_key(pass()) .create_if_missing(false), ) .unwrap(); @@ -171,7 +171,7 @@ fn open_encrypted_db_with_no_commits_uses_correct_key() { { let _db = Chisel::open( &path, - Options::default().with_encryption_key(raw_key(0x42)), + Options::default().encryption_key(raw_key(0x42)), ) .unwrap(); // Drop immediately — no begin/commit. This is the exact scenario the @@ -182,7 +182,7 @@ fn open_encrypted_db_with_no_commits_uses_correct_key() { let result = Chisel::open( &path, Options::default() - .with_encryption_key(raw_key(0x42)) + .encryption_key(raw_key(0x42)) .create_if_missing(false), ); assert!( @@ -201,7 +201,7 @@ fn named_root_round_trips_through_encrypted_open() { { let mut db = Chisel::open( &path, - Options::default().with_encryption_key(raw_key(0xAB)), + Options::default().encryption_key(raw_key(0xAB)), ) .unwrap(); db.begin().unwrap(); @@ -213,7 +213,7 @@ fn named_root_round_trips_through_encrypted_open() { let db = Chisel::open( &path, Options::default() - .with_encryption_key(raw_key(0xAB)) + .encryption_key(raw_key(0xAB)) .create_if_missing(false), ) .unwrap(); @@ -238,7 +238,7 @@ fn never_committed_encrypted_db_with_extra_superblocks_reopens() { let _db = Chisel::open( &path, Options::default() - .with_encryption_key(raw_key(0x55)) + .encryption_key(raw_key(0x55)) .superblock_count(4), ) .unwrap(); @@ -247,7 +247,7 @@ fn never_committed_encrypted_db_with_extra_superblocks_reopens() { let reopened = Chisel::open( &path, Options::default() - .with_encryption_key(raw_key(0x55)) + .encryption_key(raw_key(0x55)) .create_if_missing(false), ); assert!( @@ -272,7 +272,7 @@ fn multi_page_encrypted_value_round_trips() { { let mut db = Chisel::open( &path, - Options::default().with_encryption_key(raw_key(0x77)), + Options::default().encryption_key(raw_key(0x77)), ) .unwrap(); db.begin().unwrap(); @@ -283,7 +283,7 @@ fn multi_page_encrypted_value_round_trips() { let db = Chisel::open( &path, Options::default() - .with_encryption_key(raw_key(0x77)) + .encryption_key(raw_key(0x77)) .create_if_missing(false), ) .unwrap(); @@ -315,7 +315,7 @@ fn torn_slot_0_encrypted_db_recovers_via_sibling() { { let mut db = Chisel::open( &path, - Options::default().with_encryption_key(raw_key(0x99)), + Options::default().encryption_key(raw_key(0x99)), ) .unwrap(); // Two commits so BOTH slots (N=2) hold valid post-commit superblocks: @@ -352,7 +352,7 @@ fn torn_slot_0_encrypted_db_recovers_via_sibling() { let db = Chisel::open( &path, Options::default() - .with_encryption_key(raw_key(0x99)) + .encryption_key(raw_key(0x99)) .create_if_missing(false), ) .expect("correct key must recover a torn-slot-0 encrypted DB via its sibling"); diff --git a/tests/public_key_api.rs b/tests/public_key_api.rs new file mode 100644 index 0000000..8370eb6 --- /dev/null +++ b/tests/public_key_api.rs @@ -0,0 +1,37 @@ +// tests/public_key_api.rs — Integration test proving Key/Argon2Params are +// reachable from the crate's public surface and that Options builders work +// without naming any `crate::crypto` internal path. + +use chisel::{Argon2Params, Key, Options}; + +/// Construct Keys and Options via the public API only — this test fails to +/// compile if Key, Argon2Params, or the builders are not part of the public +/// surface. +#[test] +fn key_and_options_public_api_compiles() { + let raw = Key::Raw(zeroize::Zeroizing::new(vec![0xABu8; 32])); + let o = Options::default().encryption_key(raw); + assert!(o.encryption_key.is_some()); + assert!(o.argon2_params.is_none()); +} + +#[test] +fn passphrase_key_and_argon2_params_public_api() { + let pass = Key::Passphrase(zeroize::Zeroizing::new("hunter2".to_string())); + let params = Argon2Params { + m_cost: 32768, + t_cost: 3, + p_cost: 1, + }; + let o = Options::default().encryption_key(pass).argon2_params(params); + assert!(matches!(o.encryption_key, Some(Key::Passphrase(_)))); + let p = o.argon2_params.unwrap(); + assert_eq!(p.m_cost, 32768); +} + +#[test] +fn options_default_has_no_encryption() { + let o = Options::default(); + assert!(o.encryption_key.is_none()); + assert!(o.argon2_params.is_none()); +} From 866078b511c22593b59073007eab09d8640b59c1 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 18:09:50 -0700 Subject: [PATCH 25/42] feat(api): wire encryption_key through open_in_memory + public-API roundtrip test --- src/lib.rs | 17 +++++--- tests/encryption_roundtrip.rs | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 tests/encryption_roundtrip.rs diff --git a/src/lib.rs b/src/lib.rs index c276cce..3b5fcc9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -342,9 +342,12 @@ impl Chisel { /// # Errors /// `InvalidSuperblockCount` (the `superblock_count` option is out of /// range), `FileNotFound` (no file at `path` and `create_if_missing` is - /// false), or `LockFailed` (another handle holds the exclusive flock). When - /// reopening an existing file, parsing the superblock can also yield - /// `UnsupportedFormatVersion`, `CorruptSuperblock`, + /// false), or `LockFailed` (another handle holds the exclusive flock). + /// For an encrypted database: `NoEncryptionKey` (file is encrypted but + /// no `encryption_key` given), `InvalidEncryptionKey` (key unwraps no + /// key slot), or `EncryptionNotSupported` (key given for a plaintext + /// file). When reopening an existing file, parsing the superblock can + /// also yield `UnsupportedFormatVersion`, `CorruptSuperblock`, /// `ChecksumMismatch`, `FileSizeMismatch`, or `IoError`. pub fn open(path: &Path, options: Options) -> Result { // R4: validate superblock_count before touching the file. @@ -445,8 +448,12 @@ impl Chisel { options.drain_insertion, SpillwayLocation::InMemory, ); - // ponytail: in-memory databases never use encryption; key and argon2_params are ignored here - let txm = TransactionManager::create_new(cache, options.superblock_count, None, None)?; + let txm = TransactionManager::create_new( + cache, + options.superblock_count, + options.encryption_key.clone(), + options.argon2_params, + )?; Ok(Chisel { txm }) } diff --git a/tests/encryption_roundtrip.rs b/tests/encryption_roundtrip.rs new file mode 100644 index 0000000..2cd8af5 --- /dev/null +++ b/tests/encryption_roundtrip.rs @@ -0,0 +1,75 @@ +// tests/encryption_roundtrip.rs — end-to-end public-API encryption contract. +// +// Documents the three-case guarantee for encrypted databases: create + write +// with a key → reopen with the SAME key reads the value back; reopen with a +// WRONG key → InvalidEncryptionKey; reopen with NO key → NoEncryptionKey. +// +// Uses a raw 32-byte key to avoid paying the Argon2id cost. Passphrase +// derivation is exercised in the crypto unit tests. Uses only the public API +// (chisel::{Chisel, ChiselError, Key, Options}); no crate-internal paths. + +use chisel::{ChiselError, Chisel, Key, Options}; +use zeroize::Zeroizing; + +fn raw_key(b: u8) -> Key { + Key::Raw(Zeroizing::new(vec![b; 32])) +} + +#[test] +fn encrypted_roundtrip_and_wrong_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("enc.db"); + + // Create encrypted, write a value, capture the raw handle id, close. + let raw_handle = { + let mut db = + Chisel::open(&path, Options::default().encryption_key(raw_key(0xAB))) + .expect("create encrypted"); + db.begin().expect("begin"); + let h = db.allocate(b"secret-payload").expect("allocate"); + db.commit().expect("commit"); + h.get() + }; + + // Reopen with the SAME key: value reads back. + { + let db = Chisel::open( + &path, + Options::default() + .create_if_missing(false) + .encryption_key(raw_key(0xAB)), + ) + .expect("reopen with correct key"); + let v = db + .read(chisel::Handle::from(raw_handle)) + .expect("read after reopen"); + assert_eq!(&v, b"secret-payload"); + } + + // Reopen with the WRONG key: must return InvalidEncryptionKey. + { + let result = Chisel::open( + &path, + Options::default() + .create_if_missing(false) + .encryption_key(raw_key(0x00)), + ); + assert!(result.is_err(), "wrong key must fail"); + let err = result.err().unwrap(); + assert!( + matches!(err, ChiselError::InvalidEncryptionKey), + "expected InvalidEncryptionKey, got {err:?}" + ); + } + + // Reopen with NO key: must return NoEncryptionKey. + { + let result = Chisel::open(&path, Options::default().create_if_missing(false)); + assert!(result.is_err(), "missing key must fail"); + let err = result.err().unwrap(); + assert!( + matches!(err, ChiselError::NoEncryptionKey), + "expected NoEncryptionKey, got {err:?}" + ); + } +} From c9206a484158eafd940ce04ec8a3796eb8a57831 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 18:18:25 -0700 Subject: [PATCH 26/42] feat(python): add encryption_key kwarg to open() + exception classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 1 + python/Cargo.toml | 3 ++ python/chisel/__init__.py | 6 ++++ python/chisel/chisel.pyi | 9 +++++ python/src/db.rs | 34 ++++++++++++++++-- python/src/errors.rs | 31 ++++++++++++++++ python/tests/test_encryption.py | 64 +++++++++++++++++++++++++++++++++ 7 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 python/tests/test_encryption.py diff --git a/Cargo.lock b/Cargo.lock index 7fe1e18..72a435f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,7 @@ version = "0.1.0" dependencies = [ "chisel", "pyo3", + "zeroize", ] [[package]] diff --git a/python/Cargo.toml b/python/Cargo.toml index 66bfaa3..8883ef4 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -33,3 +33,6 @@ chisel = { path = ".." } # transitively. abi3-py311 means the wheel runs against any Python 3.11+ via # the stable ABI — that floor is preserved here. pyo3 = { version = "0.29", features = ["extension-module", "abi3-py311"] } +# Needed to construct Key::Raw(Zeroizing::new(...)) and Key::Passphrase(Zeroizing::new(...)) +# in open(). Versions must match the root crate to share the same Zeroizing type. +zeroize = { version = "1", features = ["derive"] } diff --git a/python/chisel/__init__.py b/python/chisel/__init__.py index b867368..9021138 100644 --- a/python/chisel/__init__.py +++ b/python/chisel/__init__.py @@ -46,6 +46,10 @@ CorruptPageError, InvalidPageIdError, PoisonedError, + NoEncryptionKeyError, + InvalidEncryptionKeyError, + EncryptionNotSupportedError, + DecryptionFailedError, ) @@ -138,4 +142,6 @@ class DefragStats: "FileSizeMismatchError", "LockFailedError", "UnsupportedFormatVersionError", "UnsupportedPageSizeError", "CorruptPageError", "InvalidPageIdError", "PoisonedError", + "NoEncryptionKeyError", "InvalidEncryptionKeyError", + "EncryptionNotSupportedError", "DecryptionFailedError", ] diff --git a/python/chisel/chisel.pyi b/python/chisel/chisel.pyi index ac92f44..a52cf56 100644 --- a/python/chisel/chisel.pyi +++ b/python/chisel/chisel.pyi @@ -46,6 +46,11 @@ class TransactionInProgressError(OperationalError): ... class ClosedError(OperationalError): ... class AlreadyFinishedError(OperationalError): ... class TagMismatchError(OperationalError): ... +# Encryption operational errors — database intact; caller supplied wrong or +# missing key material, or supplied a key to a plaintext database. +class NoEncryptionKeyError(OperationalError): ... +class InvalidEncryptionKeyError(OperationalError): ... +class EncryptionNotSupportedError(OperationalError): ... # Fatal errors — database is poisoned or on-disk state is suspect. @@ -71,6 +76,9 @@ class UnsupportedPageSizeError(FatalError): ... class CorruptPageError(FatalError): ... class InvalidPageIdError(FatalError): ... class PoisonedError(FatalError): ... +# Encryption fatal error: MAC verification failed on a page read after a +# successful open. Data integrity cannot be confirmed; treat as poison. +class DecryptionFailedError(FatalError): ... @dataclass(frozen=True) @@ -125,6 +133,7 @@ def open( create_if_missing: bool = True, read_only: bool = False, superblock_count: int = 2, + encryption_key: bytes | str | None = None, ) -> Chisel: ... diff --git a/python/src/db.rs b/python/src/db.rs index c329fec..727533a 100644 --- a/python/src/db.rs +++ b/python/src/db.rs @@ -145,7 +145,8 @@ impl From for chisel::DrainInsertion { drain_insertion = PyDrainInsertion::LruTail, create_if_missing = true, read_only = false, - superblock_count = 2 + superblock_count = 2, + encryption_key = None ))] // open() has 8 args; clippy warns at 7. All but `path` are keyword-only // (note the `*` in the pyo3(signature) above), so the user can never @@ -163,6 +164,7 @@ pub fn open( create_if_missing: bool, read_only: bool, superblock_count: u32, + encryption_key: Option>, ) -> PyResult { // Coerce path to PathBuf under the GIL first. Accept str fast-path // and fall back to os.fspath() for any os.PathLike (pathlib.Path, etc). @@ -185,6 +187,31 @@ pub fn open( } }; + // Coerce encryption_key under the GIL (before py.detach): `bytes` → + // Key::Raw, `str` → Key::Passphrase. Done here so a bad type raises a + // synchronous Python TypeError, matching the path coercion above. + // Key material is wrapped in Zeroizing immediately; the bytes/str borrow + // is released before the GIL drop so no key material escapes the GIL window. + let key: Option = match encryption_key { + None => None, + Some(obj) => { + let bound = obj.bind(py); + if let Ok(b) = bound.cast::() { + Some(chisel::Key::Raw(zeroize::Zeroizing::new( + b.as_bytes().to_vec(), + ))) + } else if let Ok(s) = bound.cast::() { + Some(chisel::Key::Passphrase(zeroize::Zeroizing::new( + s.to_str()?.to_owned(), + ))) + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "encryption_key must be bytes (raw key) or str (passphrase)", + )); + } + } + }; + // Resolve the spillway cap. None → Rust's 1024 × cache_max_bytes // default (8 GiB at the 8 MiB cache default); explicit 0 disables // and falls back to CacheFull-at-cap. The 1024 multiplier matches @@ -205,13 +232,16 @@ pub fn open( // builder. Every field is set explicitly because Python kwargs // already encode the caller's intent — there are no // "leave at default" cases at this boundary. - let options = chisel::Options::default() + let mut options = chisel::Options::default() .cache_max_bytes(cache_max_bytes) .spillway_max_bytes(resolved_spillway_max_bytes) .drain_insertion(drain_insertion.into()) .create_if_missing(create_if_missing) .read_only(read_only) .superblock_count(superblock_count); + if let Some(k) = key { + options = options.encryption_key(k); + } // Engine calls can block on I/O (flock, fsync, file creation), so // release the GIL while they run. Chisel is Send (single-threaded diff --git a/python/src/errors.rs b/python/src/errors.rs index 4d6136a..b7a12a5 100644 --- a/python/src/errors.rs +++ b/python/src/errors.rs @@ -114,6 +114,15 @@ create_exception!(_chisel, TransactionInProgressError, OperationalError); // left unmodified — the mismatch is purely a caller error, not a // data-integrity problem. create_exception!(_chisel, TagMismatchError, OperationalError); +// Encryption-related operational errors: the database is intact; the caller +// supplied wrong or missing key material. All three have is_fatal() = false. +// NoEncryptionKey: encrypted DB opened without an encryption_key argument. +create_exception!(_chisel, NoEncryptionKeyError, OperationalError); +// InvalidEncryptionKey: encryption_key was supplied but unwraps no key slot +// (wrong passphrase or wrong raw bytes). +create_exception!(_chisel, InvalidEncryptionKeyError, OperationalError); +// EncryptionNotSupported: encryption_key was supplied but the DB is plaintext. +create_exception!(_chisel, EncryptionNotSupportedError, OperationalError); // ISSUES.md I25: raised by PyChisel's with_inner_io/with_inner_mut_io // helpers when `inner` has been cleared by a prior close(). Distinct // from PoisonedError because close() is a user action — the DB file @@ -133,6 +142,9 @@ create_exception!(_chisel, AlreadyFinishedError, OperationalError); // Fatal — matches ChiselError::is_fatal() in src/error.rs exactly. // IoError is NOT declared here: it needs two bases (FatalError + OSError) and is // built in `register` via `build_io_error_class` / cached in `IO_ERROR_CLASS`. +// DecryptionFailed: a page-read failed MAC verification after a successful open. +// is_fatal() = true — data integrity cannot be confirmed; treat as poison. +create_exception!(_chisel, DecryptionFailedError, FatalError); create_exception!(_chisel, ChecksumMismatchError, FatalError); create_exception!(_chisel, CorruptSuperblockError, FatalError); create_exception!(_chisel, FileSizeMismatchError, FatalError); @@ -199,6 +211,15 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { py.get_type::(), )?; m.add("TagMismatchError", py.get_type::())?; + m.add("NoEncryptionKeyError", py.get_type::())?; + m.add( + "InvalidEncryptionKeyError", + py.get_type::(), + )?; + m.add( + "EncryptionNotSupportedError", + py.get_type::(), + )?; // IoError multiply-inherits (FatalError, OSError); register that class and // cache it so `to_py_err` constructs instances of it (not a single-base @@ -230,6 +251,10 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("CorruptPageError", py.get_type::())?; m.add("InvalidPageIdError", py.get_type::())?; m.add("PoisonedError", py.get_type::())?; + m.add( + "DecryptionFailedError", + py.get_type::(), + )?; Ok(()) } @@ -278,6 +303,12 @@ pub fn to_py_err(err: RustChiselError) -> PyErr { // the chunk and membership index are left intact, so it is // operational — distinct from any data-integrity problem. RustChiselError::TagMismatch { .. } => TagMismatchError::new_err(msg), + // Encryption: operational (wrong/missing key, plaintext DB). + RustChiselError::NoEncryptionKey => NoEncryptionKeyError::new_err(msg), + RustChiselError::InvalidEncryptionKey => InvalidEncryptionKeyError::new_err(msg), + RustChiselError::EncryptionNotSupported => EncryptionNotSupportedError::new_err(msg), + // Fatal encryption: MAC verification failed on a page read after open. + RustChiselError::DecryptionFailed { .. } => DecryptionFailedError::new_err(msg), // Fatal // // I42 (ISSUES.md, 2026-05-22): expose the inner io::Error's errno diff --git a/python/tests/test_encryption.py b/python/tests/test_encryption.py new file mode 100644 index 0000000..29c4746 --- /dev/null +++ b/python/tests/test_encryption.py @@ -0,0 +1,64 @@ +"""Tests for the encryption_key kwarg on chisel.open(). + +Covers: bytes (raw key) roundtrip, str (passphrase) roundtrip, wrong-key +rejection, missing-key rejection, and a bad-type TypeError. +""" + +import pathlib + +import chisel +import pytest + + +def test_encrypted_roundtrip_with_bytes_key(tmp_path: pathlib.Path): + path = tmp_path / "enc.db" + key = b"\xab" * 32 + + with chisel.open(path, encryption_key=key) as db: + db.begin() + h = db.allocate(b"secret-payload") + db.commit() + + with chisel.open(path, create_if_missing=False, encryption_key=key) as db: + assert db.read(h) == b"secret-payload" + + +def test_wrong_key_raises_invalid_encryption_key(tmp_path: pathlib.Path): + path = tmp_path / "enc.db" + with chisel.open(path, encryption_key=b"\xab" * 32) as db: + db.begin() + db.allocate(b"x") + db.commit() + + with pytest.raises(chisel.InvalidEncryptionKeyError): + chisel.open(path, create_if_missing=False, encryption_key=b"\x00" * 32) + + +def test_missing_key_raises_no_encryption_key(tmp_path: pathlib.Path): + path = tmp_path / "enc.db" + with chisel.open(path, encryption_key=b"\xab" * 32) as db: + db.begin() + db.allocate(b"x") + db.commit() + + with pytest.raises(chisel.NoEncryptionKeyError): + chisel.open(path, create_if_missing=False) + + +def test_passphrase_key_roundtrip(tmp_path: pathlib.Path): + path = tmp_path / "pass.db" + with chisel.open(path, encryption_key="correct horse battery staple") as db: + db.begin() + h = db.allocate(b"v") + db.commit() + + with chisel.open( + path, create_if_missing=False, encryption_key="correct horse battery staple" + ) as db: + assert db.read(h) == b"v" + + +def test_bad_key_type_raises_type_error(tmp_path: pathlib.Path): + path = tmp_path / "enc.db" + with pytest.raises(TypeError): + chisel.open(path, encryption_key=12345) From 7b754dbe7970e1675fb429566474bd41b493b843 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 18:27:36 -0700 Subject: [PATCH 27/42] test(python): pin encryption exception contract; trim zeroize feature 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. --- python/Cargo.toml | 5 +- python/tests/test_encryption.py | 2 + python/tests/test_exception_contract.py | 66 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/python/Cargo.toml b/python/Cargo.toml index 8883ef4..f6218dc 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -34,5 +34,6 @@ chisel = { path = ".." } # the stable ABI — that floor is preserved here. pyo3 = { version = "0.29", features = ["extension-module", "abi3-py311"] } # Needed to construct Key::Raw(Zeroizing::new(...)) and Key::Passphrase(Zeroizing::new(...)) -# in open(). Versions must match the root crate to share the same Zeroizing type. -zeroize = { version = "1", features = ["derive"] } +# in open(). Only Zeroizing::new() is used — no derive macro — so the default +# feature set suffices. Version must match the root crate to share the Zeroizing type. +zeroize = "1" diff --git a/python/tests/test_encryption.py b/python/tests/test_encryption.py index 29c4746..dd9a252 100644 --- a/python/tests/test_encryption.py +++ b/python/tests/test_encryption.py @@ -30,6 +30,8 @@ def test_wrong_key_raises_invalid_encryption_key(tmp_path: pathlib.Path): db.allocate(b"x") db.commit() + # Bare open() (no `with`) is intentional: InvalidEncryptionKey is raised at + # open time before any Chisel object exists, so there is nothing to close. with pytest.raises(chisel.InvalidEncryptionKeyError): chisel.open(path, create_if_missing=False, encryption_key=b"\x00" * 32) diff --git a/python/tests/test_exception_contract.py b/python/tests/test_exception_contract.py index a599e8e..c295f49 100644 --- a/python/tests/test_exception_contract.py +++ b/python/tests/test_exception_contract.py @@ -349,3 +349,69 @@ def test_corrupt_superblock_routes_to_fatal_class(tmp_db): # It must ALSO be a FatalError: `except chisel.FatalError` must catch every # drop-and-reopen condition (the two-tier poison contract). assert isinstance(exc_info.value, chisel.FatalError) + + +# --------------------------------------------------------------------------- +# 12. Encryption exception contract (Phase 4, Tasks 4.4 / 4.5) +# --------------------------------------------------------------------------- +# Four ChiselError encryption variants map to typed Python classes in +# to_py_err. Pin the concrete class AND the tier base for each, matching the +# per-variant contract pattern above. +# +# NoEncryptionKey -> NoEncryptionKeyError (OperationalError) +# InvalidEncryptionKey -> InvalidEncryptionKeyError (OperationalError) +# EncryptionNotSupported -> EncryptionNotSupportedError (OperationalError) +# DecryptionFailed -> DecryptionFailedError (FatalError) +# +# The three operational variants are reachable end-to-end from Python at +# open() time and are TRIGGERED below. DecryptionFailed fires only on a +# per-PAGE MAC verification failure during a read AFTER a successful open — +# it requires tampering with an encrypted data page's ciphertext/tag at a +# precise on-disk offset (the 8232-byte encrypted stride), which is the same +# "hard-to-trigger fatal, needs binding-crate test infra" class as the +# per-variant fatal coverage noted in test 11. It is therefore pinned by the +# issubclass(FatalError) hierarchy check only, not triggered here. + +RAW_KEY = b"\xcd" * 32 + + +def _make_encrypted_db(path): + """Create an encrypted DB at `path` with one committed value, then close.""" + with chisel.open(str(path), encryption_key=RAW_KEY) as db: + with db.transaction() as tx: + tx.allocate(b"secret") + + +def test_no_encryption_key_exact_class(tmp_db): + # Encrypted DB reopened with NO key -> NoEncryptionKeyError at open time. + _make_encrypted_db(tmp_db) + with pytest.raises(chisel.NoEncryptionKeyError) as exc_info: + chisel.open(str(tmp_db), create_if_missing=False) + assert isinstance(exc_info.value, chisel.OperationalError) + + +def test_invalid_encryption_key_exact_class(tmp_db): + # Encrypted DB reopened with the WRONG key -> InvalidEncryptionKeyError. + _make_encrypted_db(tmp_db) + with pytest.raises(chisel.InvalidEncryptionKeyError) as exc_info: + chisel.open(str(tmp_db), create_if_missing=False, encryption_key=b"\x00" * 32) + assert isinstance(exc_info.value, chisel.OperationalError) + + +def test_encryption_not_supported_exact_class(tmp_db): + # Plaintext DB reopened WITH a key -> EncryptionNotSupportedError. + with chisel.open(str(tmp_db)) as db: + with db.transaction() as tx: + tx.allocate(b"plain") + with pytest.raises(chisel.EncryptionNotSupportedError) as exc_info: + chisel.open(str(tmp_db), create_if_missing=False, encryption_key=RAW_KEY) + assert isinstance(exc_info.value, chisel.OperationalError) + + +def test_decryption_failed_is_fatal_hierarchy(): + # DecryptionFailed is not reachable from pure Python without precise + # per-page ciphertext tampering (see the group comment above); pin its + # tier via issubclass only. issubclass does not require an instance. + assert issubclass(chisel.DecryptionFailedError, chisel.FatalError) + assert issubclass(chisel.DecryptionFailedError, chisel.ChiselError) + assert not issubclass(chisel.DecryptionFailedError, chisel.OperationalError) From 290331651484d8e80bb390b5abff3a3ce505dfc1 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 18:55:09 -0700 Subject: [PATCH 28/42] =?UTF-8?q?feat(crypto=5Fheader):=20slot-table=20hel?= =?UTF-8?q?pers=20=E2=80=94=20unlock/free=5Fslot/wrap=5Finto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/superblock/crypto_header.rs | 199 ++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/src/superblock/crypto_header.rs b/src/superblock/crypto_header.rs index 0e36db5..edea3bf 100644 --- a/src/superblock/crypto_header.rs +++ b/src/superblock/crypto_header.rs @@ -24,6 +24,7 @@ // 102..128 reserved use crate::crypto::{Argon2Params, DEK_LEN, NONCE_LEN, SALT_LEN, TAG_LEN}; +use crate::error::ChiselError; use crate::page::{self, PAGE_SIZE}; pub const KEY_SLOT_COUNT: usize = 8; @@ -152,6 +153,204 @@ impl CryptoHeader { } Some(CryptoHeader { algorithm, stride, slots }) } + + /// Count how many slots currently hold a wrapped DEK (state == active). + /// Used by `remove_key` (Task 5.4) to guard against removing the last + /// credential and locking the caller out of their own database. + pub fn active_count(&self) -> usize { + self.slots.iter().filter(|s| s.is_active()).count() + } + + /// Index of the first non-active slot, or `None` if all 8 are occupied. + /// Maps to `ChiselError::NoFreeKeySlot` in the caller (Task 5.2). + pub fn free_slot(&self) -> Option { + self.slots.iter().position(|s| !s.is_active()) + } + + /// Try each active slot in turn: derive the KEK from `key` + the slot's + /// KDF identity, then attempt to unwrap the DEK. Returns `(slot_index, + /// dek)` for the first slot whose AEAD tag verifies. If no slot matches, + /// returns `Err(InvalidEncryptionKey)`. + /// + /// This is byte-identical to the inline trial in `recovery.rs` + /// (`unwrap_first_matching_slot`) — both call `slot.aad()` on the + /// fully-populated slot before passing it to `unwrap_dek`. + /// + /// # Errors + /// Returns `ChiselError::InvalidEncryptionKey` if no active slot's tag + /// verifies under the supplied key. + pub fn unlock( + &self, + key: &crate::crypto::Key, + ) -> Result<(usize, crate::crypto::Dek), ChiselError> { + use crate::crypto::{self, KdfId}; + for (i, slot) in self.slots.iter().enumerate() { + if !slot.is_active() { + continue; + } + // Map the on-disk kdf_id byte to the typed enum. An unrecognized + // id means a slot from a newer format — skip it rather than + // failing the whole open, so forward-compatible keys still work. + let kdf = match slot.kdf_id { + x if x == KdfId::Hkdf as u8 => KdfId::Hkdf, + x if x == KdfId::Argon2id as u8 => KdfId::Argon2id, + _ => continue, + }; + let kek = match crypto::derive_kek(key, kdf, &slot.salt, &slot.argon2) { + Ok(k) => k, + Err(_) => continue, + }; + if let Ok(dek) = crypto::unwrap_dek( + &kek, + &slot.wrapped_dek, + &slot.wrap_tag, + &slot.wrap_nonce, + &slot.aad(), + ) { + return Ok((i, dek)); + } + } + Err(ChiselError::InvalidEncryptionKey) + } + + /// Populate `slots[slot]` with a fresh random salt and nonce, wrapping + /// `dek` under the KEK derived from `key`. The KDF follows the key + /// variant: `Key::Raw` → HKDF (fast, key-material quality); + /// `Key::Passphrase` → Argon2id (memory-hard). The caller is responsible + /// for ensuring the slot index is free (use `free_slot()`) before calling. + /// + /// AAD is computed over the slot metadata BEFORE the wrapped bytes are + /// written (the `aad()` method does not read `wrapped_dek`/`wrap_tag`), + /// so the same `slot.aad()` call on the on-disk slot at open time + /// produces the exact same bytes `unlock` needs. + pub fn wrap_into( + &mut self, + slot: usize, + key: &crate::crypto::Key, + dek: &crate::crypto::Dek, + ) { + use crate::crypto::{self, KdfId}; + let (kdf_id, argon2) = match key { + crate::crypto::Key::Raw(_) => { + (KdfId::Hkdf, Argon2Params { m_cost: 0, t_cost: 0, p_cost: 0 }) + } + crate::crypto::Key::Passphrase(_) => (KdfId::Argon2id, Argon2Params::default()), + }; + let salt: [u8; SALT_LEN] = crypto::random_array(); + let wrap_nonce: [u8; NONCE_LEN] = crypto::random_array(); + let mut s = KeySlot { + state: 1, // active + kdf_id: kdf_id as u8, + argon2, + salt, + wrap_nonce, + wrapped_dek: [0u8; DEK_LEN], + wrap_tag: [0u8; TAG_LEN], + }; + // AAD is computed before the wrapped bytes are filled in: aad() reads + // state/kdf_id/argon2/salt/wrap_nonce, none of which are + // wrapped_dek/wrap_tag. This ordering matches unlock() and the + // existing recovery.rs path — all three call slot.aad() on the + // populated-but-pre-wrap slot. + let kek = crypto::derive_kek(key, kdf_id, &s.salt, &s.argon2) + .expect("fresh random salt cannot trigger a KDF parameter error"); + let (wrapped, tag) = crypto::wrap_dek(&kek, dek, &s.wrap_nonce, &s.aad()); + s.wrapped_dek = wrapped; + s.wrap_tag = tag; + self.slots[slot] = s; + } +} + +#[cfg(test)] +mod crypto_header_tests { + use super::*; + use crate::crypto::{self, Key}; + use zeroize::Zeroizing; + + fn raw(b: u8) -> Key { + Key::Raw(Zeroizing::new(vec![b; 32])) + } + + // A header with exactly one active slot holding `dek` under `key`. + fn header_with_one(key: &Key, dek: &crypto::Dek) -> CryptoHeader { + let mut h = CryptoHeader { + algorithm: 1, + stride: crypto::ENC_PAGE_SIZE as u32, + slots: [KeySlot::EMPTY; KEY_SLOT_COUNT], + }; + h.wrap_into(0, key, dek); + h + } + + #[test] + fn unlock_finds_the_right_slot_and_recovers_dek() { + let dek = crypto::random_dek(); + let k0 = raw(0xA1); + let mut h = header_with_one(&k0, &dek); + + // Add a second credential into slot 3 wrapping the SAME dek. + let k1 = raw(0xB2); + h.wrap_into(3, &k1, &dek); + + let (idx0, d0) = h.unlock(&k0).expect("k0 must unlock"); + let (idx1, d1) = h.unlock(&k1).expect("k1 must unlock"); + assert_eq!(idx0, 0); + assert_eq!(idx1, 3); + // Both recover the identical DEK bytes. + assert_eq!(d0.as_bytes(), dek.as_bytes()); + assert_eq!(d1.as_bytes(), dek.as_bytes()); + } + + #[test] + fn unlock_wrong_key_returns_invalid_encryption_key() { + let dek = crypto::random_dek(); + let h = header_with_one(&raw(0xAA), &dek); + // Dek has no Debug, so we can't use expect_err(); use matches! instead. + let result = h.unlock(&raw(0xBB)); + assert!(matches!(result, Err(crate::error::ChiselError::InvalidEncryptionKey))); + } + + #[test] + fn unlock_empty_header_returns_error() { + let h = CryptoHeader { + algorithm: 1, + stride: crypto::ENC_PAGE_SIZE as u32, + slots: [KeySlot::EMPTY; KEY_SLOT_COUNT], + }; + assert!(h.unlock(&raw(0x01)).is_err()); + } + + #[test] + fn free_slot_and_active_count_track_occupancy() { + let dek = crypto::random_dek(); + let mut h = header_with_one(&raw(0x01), &dek); + assert_eq!(h.active_count(), 1); + assert_eq!(h.free_slot(), Some(1)); + + // Fill every remaining slot. + for i in 1..KEY_SLOT_COUNT { + h.wrap_into(i, &raw(i as u8 + 1), &dek); + } + assert_eq!(h.active_count(), KEY_SLOT_COUNT); + assert_eq!(h.free_slot(), None); + } + + #[test] + fn wrap_into_then_unlock_round_trips_dek() { + // Verify that a freshly wrapped slot's AAD bytes at wrap time match + // those recomputed at unlock time (the crux of Task 5.1). + let dek = crypto::random_dek(); + let key = raw(0x77); + let mut h = CryptoHeader { + algorithm: 1, + stride: crypto::ENC_PAGE_SIZE as u32, + slots: [KeySlot::EMPTY; KEY_SLOT_COUNT], + }; + h.wrap_into(5, &key, &dek); + let (idx, recovered) = h.unlock(&key).expect("wrap_into then unlock must succeed"); + assert_eq!(idx, 5); + assert_eq!(recovered.as_bytes(), dek.as_bytes()); + } } #[cfg(test)] From d218a368f717fe77145966c76d5c1ed7b6429bf0 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 19:19:39 -0700 Subject: [PATCH 29/42] feat(crypto): rewrite_crypto_header metadata-only superblock commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/transaction/keys.rs | 346 ++++++++++++++++++++++++++++++++++++++++ src/transaction/mod.rs | 1 + 2 files changed, 347 insertions(+) create mode 100644 src/transaction/keys.rs diff --git a/src/transaction/keys.rs b/src/transaction/keys.rs new file mode 100644 index 0000000..4b7edef --- /dev/null +++ b/src/transaction/keys.rs @@ -0,0 +1,346 @@ +//! transaction::keys — out-of-band key-slot management. +//! +//! Each operation here rewrites ONLY the in-superblock CryptoHeader and commits +//! it via the ordinary A/B superblock slot rotation — no data pages are touched +//! and the per-DB DEK never changes, so there is no re-encryption. +//! +//! Durability is identical to a data commit: bump txn_counter, write the inactive +//! slot, fsync (the linearization point), then promote the in-memory header. +//! A crash before the fsync returns leaves the OLD superblock in its slot; recovery +//! always picks the highest-txn_counter slot with a valid checksum, so the old +//! key-slot table is intact. +//! +//! Guards: +//! - Refuses if the manager is poisoned (I1 model). +//! - Refuses if an active user transaction is in flight — a key-rotation is its +//! own atomic superblock write and cannot be interleaved with a data commit. +//! - Refuses if the database is plaintext (no cipher). + +use super::*; +use crate::superblock::CryptoHeader; + +impl TransactionManager { + /// Write a superblock carrying `new_header` into the inactive slot, leaving + /// every committed data root (handle-table page, freemap page, named roots, + /// membership index, total pages, next handle, freemap depth) untouched. + /// + /// This is the crash-safe linearization point for credential rotation: the + /// DEK does not change, so the superblock body is re-sealed under the same + /// session `PageCipher`. Bumping `txn_counter` ensures the new slot wins on + /// recovery. The in-memory promotion of `crypto_header` happens only AFTER + /// the fsync returns. + /// + /// # Errors + /// - `ChiselError::Poisoned` — manager is in the poison state. + /// - `ChiselError::TransactionInProgress` — an active user transaction exists. + /// - `ChiselError::EncryptionNotSupported` — this is a plaintext database. + /// - I/O errors from the cache flush, write, or fsync — all fatal (poison). + // ponytail: callers added in Tasks 5.3/5.4 (add_key, rotate_key, remove_key) + #[allow(dead_code)] + pub(crate) fn rewrite_crypto_header(&mut self, new_header: CryptoHeader) -> Result<()> { + self.check_alive()?; + if self.active_txn { + return Err(ChiselError::TransactionInProgress); + } + if self.cipher.is_none() { + return Err(ChiselError::EncryptionNotSupported); + } + // All errors past this point are fatal: after flush() the cache dirty + // flags are cleared; any subsequent failure is indistinguishable from a + // mid-commit crash under fsyncgate semantics. + let result = self.rewrite_crypto_header_inner(new_header); + if result.is_err() { + self.poisoned.set(true); + } + result + } + + fn rewrite_crypto_header_inner(&mut self, new_header: CryptoHeader) -> Result<()> { + let mut cache = self.cache.borrow_mut(); + // flush() ensures any dirty pages in the spillway are durable before the + // new superblock references them. Between transactions the cache should + // normally be clean, but the flush keeps the invariant honest against + // future changes. + cache.flush()?; + + // I119: use checked_add, not `+= 1`. A wrapped counter corrupts + // Superblock::select's "highest counter wins" rule on recovery. + self.txn_counter = self + .txn_counter + .checked_add(1) + .expect("txn_counter overflowed u64 (2^64 commits) — unreachable"); + + let total_pages = cache.file_page_count()?; + let r = &self.committed_roots; + let sb = Superblock { + magic: page::MAGIC, + // Encrypted DBs always use the encrypted format version (MAJOR=2) so + // an old binary rejects them rather than silently misreading them. + format_version: page::format_version_encrypted(), + txn_counter: self.txn_counter, + root_handle_table_page: r.handle_table_page, + root_freemap_page: r.freemap_page, + total_pages, + next_handle: r.next_handle, + page_size: PAGE_SIZE as u32, + named_roots: r.named_roots, + superblock_count: self.superblock_count, + root_membership_index_page: r.membership_index_page, + freemap_depth: r.freemap_depth, + // The new key-slot table; the DEK inside `cipher` is unchanged. + encryption: Some(new_header), + }; + + // Seal the sensitive superblock body under the session DEK. + // `cipher` is Some because we checked at entry. + let buf = sb.serialize_encrypted(self.cipher.as_ref().expect("cipher checked at entry")); + + // Write to the INACTIVE slot (same round-robin as the data commit path). + // With N=2 this is parity alternation; with N≥3 true round-robin. + let inactive = self.txn_counter % self.superblock_count as u64; + + // Encrypted DB: stride is ENC_PAGE_SIZE=8232, so write_page (which + // asserts stride==PAGE_SIZE) would panic. Zero-pad to ENC_PAGE_SIZE and + // use write_page_unit, mirroring exactly what commit.rs does. + { + use crate::crypto::ENC_PAGE_SIZE; + let mut unit = [0u8; ENC_PAGE_SIZE]; + unit[..buf.len()].copy_from_slice(&buf); + cache.io_mut().write_page_unit(inactive, &unit)?; + } + + // Durability linearization point: the rewrite is crash-safe only after + // this fsync returns. A crash before this leaves the old superblock + // intact in the other slot; recovery picks it by highest txn_counter. + cache.io_mut().fsync()?; + + // In-memory promotion: the new slot table becomes the authoritative + // header ONLY after the fsync, matching the data commit convention. + self.crypto_header = Some(new_header); + // total_pages may have advanced if a prior data commit grew the file; + // keep committed_roots in sync. + self.committed_roots.total_pages = total_pages; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::Key; + use crate::page_io::PageIo; + use tempfile::NamedTempFile; + use zeroize::Zeroizing; + + fn raw(b: u8) -> Key { + Key::Raw(Zeroizing::new(vec![b; 32])) + } + + /// Build an encrypted in-memory-backed TransactionManager with a fresh DB. + fn fresh_encrypted() -> TransactionManager { + let file = NamedTempFile::new().unwrap(); + let io = PageIo::open(file.path(), false).unwrap(); + let cache = PageCache::new( + io, + 1024 * PAGE_SIZE as u64, + 0, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + let mut tm = + TransactionManager::create_new(cache, 2, Some(raw(0x11)), None).unwrap(); + // Commit once so there is a real baseline superblock to read/write. + tm.begin().unwrap(); + tm.commit().unwrap(); + tm + } + + /// Build a plaintext (unencrypted) TransactionManager. + fn fresh_plaintext() -> TransactionManager { + let file = NamedTempFile::new().unwrap(); + let io = PageIo::open(file.path(), false).unwrap(); + let cache = PageCache::new( + io, + 1024 * PAGE_SIZE as u64, + 0, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + let mut tm = TransactionManager::create_new(cache, 2, None, None).unwrap(); + tm.begin().unwrap(); + tm.commit().unwrap(); + tm + } + + // ── guard tests ──────────────────────────────────────────────────────────── + + /// Plaintext DB must reject rewrite_crypto_header with EncryptionNotSupported. + #[test] + fn plaintext_db_rejects_rewrite_crypto_header() { + let mut db = fresh_plaintext(); + let hdr = CryptoHeader { + algorithm: 1, + stride: 8232, + slots: [crate::superblock::KeySlot::EMPTY; crate::superblock::KEY_SLOT_COUNT], + }; + let err = db.rewrite_crypto_header(hdr).unwrap_err(); + assert!( + matches!(err, ChiselError::EncryptionNotSupported), + "expected EncryptionNotSupported, got {err:?}" + ); + } + + /// An active transaction must cause TransactionInProgress. + #[test] + fn active_txn_rejects_rewrite_crypto_header() { + let mut db = fresh_encrypted(); + db.begin().unwrap(); + let hdr = db.crypto_header.unwrap(); + let err = db.rewrite_crypto_header(hdr).unwrap_err(); + assert!( + matches!(err, ChiselError::TransactionInProgress), + "expected TransactionInProgress, got {err:?}" + ); + db.rollback().unwrap(); + } + + /// A poisoned manager must refuse immediately with Poisoned. + #[test] + fn poisoned_manager_rejects_rewrite_crypto_header() { + let mut db = fresh_encrypted(); + db.force_poison_for_test(); + let hdr = db.crypto_header.unwrap(); + let err = db.rewrite_crypto_header(hdr).unwrap_err(); + assert!( + matches!(err, ChiselError::Poisoned), + "expected Poisoned, got {err:?}" + ); + } + + // ── state mutation tests ─────────────────────────────────────────────────── + + /// After rewrite_crypto_header the in-memory header reflects the new slot + /// table and txn_counter advances (proving a superblock write occurred). + #[test] + fn rewrite_crypto_header_updates_in_memory_state() { + let mut db = fresh_encrypted(); + let counter_before = db.txn_counter; + + // Unlock slot 0 to get the DEK, then wrap it into a second slot. + let mut new_hdr = db.crypto_header.expect("encrypted DB must have crypto_header"); + let (_, dek) = new_hdr.unlock(&raw(0x11)).expect("slot 0 unlocks with key 0x11"); + new_hdr.wrap_into(1, &raw(0x22), &dek); + + db.rewrite_crypto_header(new_hdr).unwrap(); + + // txn_counter must have bumped exactly once. + assert_eq!(db.txn_counter, counter_before + 1, "txn_counter must advance"); + // In-memory header must reflect both active slots. + let stored = db.crypto_header.expect("crypto_header must be Some after rewrite"); + assert_eq!(stored.active_count(), 2, "both slots must be active"); + assert!(!db.is_poisoned()); + } + + /// Rewrite must preserve every committed data root — only total_pages and + /// txn_counter are allowed to change. + #[test] + fn rewrite_crypto_header_preserves_data_roots() { + let mut db = fresh_encrypted(); + let roots_before = db.committed_roots.clone(); + + let hdr = db.crypto_header.unwrap(); + db.rewrite_crypto_header(hdr).unwrap(); + + let r = &db.committed_roots; + assert_eq!(r.handle_table_page, roots_before.handle_table_page); + assert_eq!(r.freemap_page, roots_before.freemap_page); + assert_eq!(r.next_handle, roots_before.next_handle); + assert_eq!(r.named_roots, roots_before.named_roots); + assert_eq!(r.membership_index_page, roots_before.membership_index_page); + assert_eq!(r.freemap_depth, roots_before.freemap_depth); + } + + // ── A/B slot rotation test ───────────────────────────────────────────────── + + /// Two successive rewrites must target alternating slots (round-robin), and + /// each must advance the txn_counter so the latest write always wins on + /// recovery. + #[test] + fn rewrite_alternates_superblock_slots() { + let mut db = fresh_encrypted(); + // After fresh_encrypted: one create + one data commit = txn_counter=3 + // (create writes N=2 initial slots + one commit). The next write targets + // txn_counter % 2. + // CryptoHeader is Copy so we can just use the value twice. + let hdr: CryptoHeader = db.crypto_header.unwrap(); + let counter0 = db.txn_counter; + + // First rewrite. + db.rewrite_crypto_header(hdr).unwrap(); + let counter1 = db.txn_counter; + assert_eq!(counter1, counter0 + 1); + + // Second rewrite targets the other slot. + db.rewrite_crypto_header(hdr).unwrap(); + let counter2 = db.txn_counter; + assert_eq!(counter2, counter1 + 1); + + // Slot parity flips each time. + assert_ne!( + counter1 % db.superblock_count as u64, + counter2 % db.superblock_count as u64, + "successive rewrites must target different superblock slots" + ); + } + + // ── durability: reopen reads back the rewritten header ──────────────────── + + /// After rewrite_crypto_header, reopening the file with the NEW key must + /// succeed (the new slot is on disk), and the OLD key must still work (it + /// was not removed). Verifies end-to-end persistence through the on-disk + /// superblock write. + #[test] + fn rewritten_header_persists_across_reopen() { + use crate::page_io::PageIo; + use tempfile::TempDir; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + + // Create an encrypted DB with key 0x11. + let io = PageIo::open(&path, false).unwrap(); + let cache = PageCache::new( + io, + 1024 * PAGE_SIZE as u64, + 0, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + let mut db = TransactionManager::create_new(cache, 2, Some(raw(0x11)), None).unwrap(); + db.begin().unwrap(); + db.commit().unwrap(); + + // Add key 0x22 by rewriting the header with a second slot. + let mut new_hdr = db.crypto_header.unwrap(); + let (_, dek) = new_hdr.unlock(&raw(0x11)).unwrap(); + new_hdr.wrap_into(1, &raw(0x22), &dek); + db.rewrite_crypto_header(new_hdr).unwrap(); + // Drop to flush OS buffers (fsync already called). + drop(db); + + // Reopen with the SECOND key — must succeed (proves the rewrite hit disk). + let io2 = PageIo::open(&path, false).unwrap(); + let cache2 = PageCache::new( + io2, + 1024 * PAGE_SIZE as u64, + 0, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + let db2 = TransactionManager::open_existing(cache2, Some(raw(0x22))).unwrap(); + assert!(!db2.is_poisoned()); + let stored = db2.crypto_header.unwrap(); + assert_eq!(stored.active_count(), 2, "both slots must survive reopen"); + } +} diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index 6ae95b5..927b6ee 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -241,6 +241,7 @@ mod config; #[cfg(test)] mod fault; mod freemap; +mod keys; mod lifecycle; mod mutate; mod named_roots; From 6ba727762b508fca3c15c9db7b2f5f5bdf477359 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 19:32:42 -0700 Subject: [PATCH 30/42] feat(crypto): Chisel::add_key and rotate_key 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. --- src/lib.rs | 28 +++++++ src/transaction/keys.rs | 46 ++++++++++- tests/encryption_keys.rs | 173 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 tests/encryption_keys.rs diff --git a/src/lib.rs b/src/lib.rs index 3b5fcc9..d92f0af 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -942,6 +942,34 @@ impl Chisel { pub fn set_drain_insertion(&mut self, policy: DrainInsertion) -> Result<()> { self.txm.set_drain_insertion(policy) } + + /// Add a second credential that unlocks this database. `existing` must + /// already unlock it; `new` is wrapped over the same data key into a free + /// key slot. After this returns, either credential opens the database. O(1) + /// superblock commit — no page is re-encrypted. + /// + /// # Errors + /// `EncryptionNotSupported` if the database has no encryption; + /// `InvalidEncryptionKey` if `existing` unlocks no slot; `NoFreeKeySlot` if + /// all 8 key slots are full. An fsync/superblock failure is fatal and poisons + /// the handle. + pub fn add_key(&mut self, existing: &crypto::Key, new: &crypto::Key) -> Result<()> { + self.txm.add_key(existing, new) + } + + /// Replace `old` with `new`: `new` is added and `old` is revoked in one + /// atomic superblock commit. After this returns, `old` no longer opens the + /// database and `new` does. O(1) — the data key is unchanged, no page is + /// re-encrypted. + /// + /// # Errors + /// `EncryptionNotSupported` if the database has no encryption; + /// `InvalidEncryptionKey` if `old` unlocks no slot; `NoFreeKeySlot` if all 8 + /// key slots are full (no room to stage `new` before revoking `old`). An + /// fsync/superblock failure is fatal and poisons the handle. + pub fn rotate_key(&mut self, old: &crypto::Key, new: &crypto::Key) -> Result<()> { + self.txm.rotate_key(old, new) + } } #[cfg(test)] diff --git a/src/transaction/keys.rs b/src/transaction/keys.rs index 4b7edef..db33c06 100644 --- a/src/transaction/keys.rs +++ b/src/transaction/keys.rs @@ -35,8 +35,6 @@ impl TransactionManager { /// - `ChiselError::TransactionInProgress` — an active user transaction exists. /// - `ChiselError::EncryptionNotSupported` — this is a plaintext database. /// - I/O errors from the cache flush, write, or fsync — all fatal (poison). - // ponytail: callers added in Tasks 5.3/5.4 (add_key, rotate_key, remove_key) - #[allow(dead_code)] pub(crate) fn rewrite_crypto_header(&mut self, new_header: CryptoHeader) -> Result<()> { self.check_alive()?; if self.active_txn { @@ -123,6 +121,50 @@ impl TransactionManager { Ok(()) } + + /// Prove possession of `existing` (it must unlock some active slot), recover + /// the DEK, then wrap that SAME DEK under `new` in a free slot and commit the + /// new header. The DEK is unchanged, so existing pages stay readable under + /// both credentials after this returns. + /// + /// # Errors + /// `EncryptionNotSupported` — plaintext DB; `InvalidEncryptionKey` — `existing` + /// unlocks no slot; `NoFreeKeySlot` — all 8 slots occupied; I/O failures are + /// fatal and poison the manager. + pub(crate) fn add_key(&mut self, existing: &crate::crypto::Key, new: &crate::crypto::Key) -> Result<()> { + if self.poisoned.get() { + return Err(ChiselError::Poisoned); + } + let header = self.crypto_header.as_ref().ok_or(ChiselError::EncryptionNotSupported)?; + let (_idx, dek) = header.unlock(existing)?; // → InvalidEncryptionKey if none + let free = header.free_slot().ok_or(ChiselError::NoFreeKeySlot)?; + let mut new_header = *header; + new_header.wrap_into(free, new, &dek); + self.rewrite_crypto_header(new_header) + } + + /// Replace `old` with `new` in a single atomic superblock write. `new` is + /// staged into a free slot BEFORE the old slot is cleared, so there is never + /// a window with zero working credentials — a crash leaves either the + /// pre-rotation header (old works) or the post-rotation header (new works). + /// + /// # Errors + /// `EncryptionNotSupported` — plaintext DB; `InvalidEncryptionKey` — `old` + /// unlocks no slot; `NoFreeKeySlot` — all 8 slots full (no room to stage + /// `new` before revoking `old`); I/O failures are fatal and poison the manager. + pub(crate) fn rotate_key(&mut self, old: &crate::crypto::Key, new: &crate::crypto::Key) -> Result<()> { + if self.poisoned.get() { + return Err(ChiselError::Poisoned); + } + let header = self.crypto_header.as_ref().ok_or(ChiselError::EncryptionNotSupported)?; + let (old_idx, dek) = header.unlock(old)?; // → InvalidEncryptionKey if none + let free = header.free_slot().ok_or(ChiselError::NoFreeKeySlot)?; + let mut new_header = *header; + new_header.wrap_into(free, new, &dek); + // Clear the old slot in the same header snapshot — single atomic rewrite. + new_header.slots[old_idx] = crate::superblock::KeySlot::EMPTY; + self.rewrite_crypto_header(new_header) + } } #[cfg(test)] diff --git a/tests/encryption_keys.rs b/tests/encryption_keys.rs new file mode 100644 index 0000000..d8aa808 --- /dev/null +++ b/tests/encryption_keys.rs @@ -0,0 +1,173 @@ +//! Integration tests for Chisel::add_key and Chisel::rotate_key. +//! +//! Each test uses the public API only (Key / Chisel / ChiselError / Options). +//! The underlying DEK is never re-generated, so add_key / rotate_key are pure +//! superblock operations: no page is touched, data survives every credential change. + +use chisel::{ChiselError, Chisel, Options}; +use chisel::Key; +use tempfile::TempDir; +use zeroize::Zeroizing; + +fn raw(b: u8) -> Key { + Key::Raw(Zeroizing::new(vec![b; 32])) +} + +// ── add_key ────────────────────────────────────────────────────────────────── + +/// After add_key, the original key and the new key both open the database and +/// decrypt the same data (the DEK is shared between slots). +#[test] +fn add_key_lets_either_credential_open() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + + let h = { + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + db.begin().unwrap(); + let h = db.allocate(b"secret").unwrap(); + db.commit().unwrap(); + db.add_key(&raw(1), &raw(2)).unwrap(); + db.close().unwrap(); + h + }; + + // Original key still decrypts. + let db1 = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + assert_eq!(db1.read(h).unwrap(), b"secret"); + db1.close().unwrap(); + + // New key also decrypts the same data (same DEK, different slot). + let db2 = Chisel::open(&path, Options::default().encryption_key(raw(2))).unwrap(); + assert_eq!(db2.read(h).unwrap(), b"secret"); + db2.close().unwrap(); +} + +/// Wrong `existing` key returns InvalidEncryptionKey; the database is unmodified. +#[test] +fn add_key_wrong_existing_is_invalid_encryption_key() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + let err = db.add_key(&raw(9), &raw(2)).unwrap_err(); + assert!( + matches!(err, ChiselError::InvalidEncryptionKey), + "expected InvalidEncryptionKey, got {err:?}" + ); + assert!(!db.is_poisoned()); +} + +/// Filling all 8 key slots returns NoFreeKeySlot on the ninth attempt. +#[test] +fn add_key_full_table_is_no_free_key_slot() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + // Slot 0 is occupied by raw(1) at open; add keys 2..=8 to fill the other 7. + for k in 2u8..=8 { + db.add_key(&raw(1), &raw(k)).unwrap(); + } + let err = db.add_key(&raw(1), &raw(99)).unwrap_err(); + assert!( + matches!(err, ChiselError::NoFreeKeySlot), + "expected NoFreeKeySlot, got {err:?}" + ); + assert!(!db.is_poisoned()); +} + +/// add_key on a plaintext database returns EncryptionNotSupported. +#[test] +fn add_key_plaintext_db_returns_encryption_not_supported() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default()).unwrap(); + let err = db.add_key(&raw(1), &raw(2)).unwrap_err(); + assert!( + matches!(err, ChiselError::EncryptionNotSupported), + "expected EncryptionNotSupported, got {err:?}" + ); + assert!(!db.is_poisoned()); +} + +// ── rotate_key ─────────────────────────────────────────────────────────────── + +/// After rotate_key(old, new): old no longer opens, new does, and data is intact. +#[test] +fn rotate_key_revokes_old_and_admits_new() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + + let h = { + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + db.begin().unwrap(); + let h = db.allocate(b"data").unwrap(); + db.commit().unwrap(); + db.rotate_key(&raw(1), &raw(2)).unwrap(); + db.close().unwrap(); + h + }; + + // Old key is now refused. + let err = Chisel::open(&path, Options::default().encryption_key(raw(1))) + .err() + .expect("old key must be rejected after rotate"); + assert!( + matches!(err, ChiselError::InvalidEncryptionKey), + "expected InvalidEncryptionKey, got {err:?}" + ); + + // New key opens and data is readable. + let db = Chisel::open(&path, Options::default().encryption_key(raw(2))).unwrap(); + assert!(!db.is_poisoned()); + assert_eq!(db.read(h).unwrap(), b"data"); + db.close().unwrap(); +} + +/// rotate_key on a plaintext database returns EncryptionNotSupported. +#[test] +fn rotate_key_plaintext_db_returns_encryption_not_supported() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default()).unwrap(); + let err = db.rotate_key(&raw(1), &raw(2)).unwrap_err(); + assert!( + matches!(err, ChiselError::EncryptionNotSupported), + "expected EncryptionNotSupported, got {err:?}" + ); + assert!(!db.is_poisoned()); +} + +/// rotate_key with a wrong `old` key returns InvalidEncryptionKey. +#[test] +fn rotate_key_wrong_old_is_invalid_encryption_key() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + let err = db.rotate_key(&raw(9), &raw(2)).unwrap_err(); + assert!( + matches!(err, ChiselError::InvalidEncryptionKey), + "expected InvalidEncryptionKey, got {err:?}" + ); + assert!(!db.is_poisoned()); +} + +/// rotate_key when the slot table is full (no room to stage new) returns +/// NoFreeKeySlot. The old slot is NOT pre-cleared to make room, since that +/// would create a zero-key window on crash. +#[test] +fn rotate_key_full_table_is_no_free_key_slot() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + // Fill all 8 slots — slot 0 is raw(1), add 7 more. + for k in 2u8..=8 { + db.add_key(&raw(1), &raw(k)).unwrap(); + } + // Full table: rotate must refuse rather than clear old first. + let err = db.rotate_key(&raw(1), &raw(99)).unwrap_err(); + assert!( + matches!(err, ChiselError::NoFreeKeySlot), + "expected NoFreeKeySlot, got {err:?}" + ); + assert!(!db.is_poisoned()); +} From 0d3fce9490de30d9ed4836fe268a84e68b2f731b Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 19:43:29 -0700 Subject: [PATCH 31/42] feat(crypto): Chisel::remove_key with last-slot guard --- src/lib.rs | 15 +++++++ src/transaction/keys.rs | 30 ++++++++++++++ tests/encryption_keys.rs | 84 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index d92f0af..5ee4406 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -970,6 +970,21 @@ impl Chisel { pub fn rotate_key(&mut self, old: &crypto::Key, new: &crypto::Key) -> Result<()> { self.txm.rotate_key(old, new) } + + /// Revoke the credential `key`. After this returns, `key` no longer opens + /// the database; any other credentials are unaffected. Refuses to remove + /// the only remaining credential. O(1) — the data key is unchanged, no + /// page is re-encrypted. + /// + /// # Errors + /// `EncryptionNotSupported` if the database has no encryption; + /// `InvalidEncryptionKey` if `key` unlocks no slot; `LastKeySlot` if + /// `key` is the only active credential (removing it would make the database + /// permanently unopenable — nothing is changed). An fsync/superblock + /// failure is fatal and poisons the handle. + pub fn remove_key(&mut self, key: &crypto::Key) -> Result<()> { + self.txm.remove_key(key) + } } #[cfg(test)] diff --git a/src/transaction/keys.rs b/src/transaction/keys.rs index db33c06..2367327 100644 --- a/src/transaction/keys.rs +++ b/src/transaction/keys.rs @@ -165,6 +165,36 @@ impl TransactionManager { new_header.slots[old_idx] = crate::superblock::KeySlot::EMPTY; self.rewrite_crypto_header(new_header) } + + /// Clear the slot `key` unlocks. Refuses to remove the LAST active slot + /// (`LastKeySlot`) — a database with zero usable credentials is + /// unrecoverable, so this is an operational error that changes nothing. + /// + /// The last-slot check happens AFTER proving the supplied key is valid, so + /// a key that unlocks nothing on a single-slot DB gets `InvalidEncryptionKey` + /// rather than the more confusing `LastKeySlot`. + /// + /// # Errors + /// `Poisoned` — manager is in the poison state; `EncryptionNotSupported` — + /// plaintext DB; `InvalidEncryptionKey` — `key` unlocks no slot; + /// `LastKeySlot` — `key` IS the only active credential (removal refused, + /// nothing is changed). I/O failures from the superblock rewrite are fatal + /// and poison the manager. + pub(crate) fn remove_key(&mut self, key: &crate::crypto::Key) -> Result<()> { + if self.poisoned.get() { + return Err(ChiselError::Poisoned); + } + let header = self.crypto_header.as_ref().ok_or(ChiselError::EncryptionNotSupported)?; + let (idx, _dek) = header.unlock(key)?; // → InvalidEncryptionKey if none + // Check AFTER confirming the key is valid: an unknown key on a + // single-slot DB should report InvalidEncryptionKey, not LastKeySlot. + if header.active_count() <= 1 { + return Err(ChiselError::LastKeySlot); + } + let mut new_header = *header; + new_header.slots[idx] = crate::superblock::KeySlot::EMPTY; + self.rewrite_crypto_header(new_header) + } } #[cfg(test)] diff --git a/tests/encryption_keys.rs b/tests/encryption_keys.rs index d8aa808..ba1e69e 100644 --- a/tests/encryption_keys.rs +++ b/tests/encryption_keys.rs @@ -1,4 +1,4 @@ -//! Integration tests for Chisel::add_key and Chisel::rotate_key. +//! Integration tests for Chisel::add_key, Chisel::rotate_key, and Chisel::remove_key. //! //! Each test uses the public API only (Key / Chisel / ChiselError / Options). //! The underlying DEK is never re-generated, so add_key / rotate_key are pure @@ -171,3 +171,85 @@ fn rotate_key_full_table_is_no_free_key_slot() { ); assert!(!db.is_poisoned()); } + +// ── remove_key ─────────────────────────────────────────────────────────────── + +/// After remove_key the revoked credential is rejected at open; all other +/// credentials continue to decrypt the same data (DEK is shared across slots). +#[test] +fn remove_key_leaves_others_working() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let h = { + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + db.begin().unwrap(); + let h = db.allocate(b"v").unwrap(); + db.commit().unwrap(); + db.add_key(&raw(1), &raw(2)).unwrap(); + db.remove_key(&raw(1)).unwrap(); // drop the first credential + db.close().unwrap(); + h + }; + // raw(1) is gone — open must fail. + let err = Chisel::open(&path, Options::default().encryption_key(raw(1))) + .err() + .expect("old key must be rejected after remove"); + assert!( + matches!(err, ChiselError::InvalidEncryptionKey), + "expected InvalidEncryptionKey, got {err:?}" + ); + // raw(2) still opens and reads the original data. + let db = Chisel::open(&path, Options::default().encryption_key(raw(2))).unwrap(); + assert_eq!(db.read(h).unwrap(), b"v"); + db.close().unwrap(); +} + +/// remove_key with the only active credential returns LastKeySlot and leaves +/// the database intact (the rejected op must not mutate anything). +#[test] +fn remove_last_key_is_rejected() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + // Only one active slot — removing it would permanently brick the database. + let err = db.remove_key(&raw(1)).unwrap_err(); + assert!( + matches!(err, ChiselError::LastKeySlot), + "expected LastKeySlot, got {err:?}" + ); + assert!(!db.is_poisoned()); + // Reopen with the same key to confirm nothing was mutated. + drop(db); + let db2 = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + assert!(!db2.is_poisoned()); + db2.close().unwrap(); +} + +/// remove_key with a key that unlocks no slot returns InvalidEncryptionKey. +#[test] +fn remove_unknown_key_is_invalid_encryption_key() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw(1))).unwrap(); + db.add_key(&raw(1), &raw(2)).unwrap(); + let err = db.remove_key(&raw(9)).unwrap_err(); + assert!( + matches!(err, ChiselError::InvalidEncryptionKey), + "expected InvalidEncryptionKey, got {err:?}" + ); + assert!(!db.is_poisoned()); +} + +/// remove_key on a plaintext database returns EncryptionNotSupported. +#[test] +fn remove_key_plaintext_db_returns_encryption_not_supported() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("db"); + let mut db = Chisel::open(&path, Options::default()).unwrap(); + let err = db.remove_key(&raw(1)).unwrap_err(); + assert!( + matches!(err, ChiselError::EncryptionNotSupported), + "expected EncryptionNotSupported, got {err:?}" + ); + assert!(!db.is_poisoned()); +} From 1c0d981475b3ce14183a27734b90bdf36de36c45 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 19:51:39 -0700 Subject: [PATCH 32/42] feat(python): bind add_key/rotate_key/remove_key; add NoFreeKeySlotError/LastKeySlotError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- python/chisel/__init__.py | 3 + python/chisel/chisel.pyi | 12 ++ python/src/db.rs | 67 +++++++++ python/src/errors.rs | 15 ++ python/tests/test_encryption_keys.py | 181 ++++++++++++++++++++++++ python/tests/test_exception_contract.py | 42 ++++++ 6 files changed, 320 insertions(+) create mode 100644 python/tests/test_encryption_keys.py diff --git a/python/chisel/__init__.py b/python/chisel/__init__.py index 9021138..cb61a4f 100644 --- a/python/chisel/__init__.py +++ b/python/chisel/__init__.py @@ -50,6 +50,8 @@ InvalidEncryptionKeyError, EncryptionNotSupportedError, DecryptionFailedError, + NoFreeKeySlotError, + LastKeySlotError, ) @@ -144,4 +146,5 @@ class DefragStats: "PoisonedError", "NoEncryptionKeyError", "InvalidEncryptionKeyError", "EncryptionNotSupportedError", "DecryptionFailedError", + "NoFreeKeySlotError", "LastKeySlotError", ] diff --git a/python/chisel/chisel.pyi b/python/chisel/chisel.pyi index a52cf56..7128c86 100644 --- a/python/chisel/chisel.pyi +++ b/python/chisel/chisel.pyi @@ -51,6 +51,9 @@ class TagMismatchError(OperationalError): ... class NoEncryptionKeyError(OperationalError): ... class InvalidEncryptionKeyError(OperationalError): ... class EncryptionNotSupportedError(OperationalError): ... +# Key-rotation capacity/safety guards — database intact; caller hit a limit. +class NoFreeKeySlotError(OperationalError): ... +class LastKeySlotError(OperationalError): ... # Fatal errors — database is poisoned or on-disk state is suspect. @@ -179,6 +182,15 @@ class Chisel: def defrag(self, options: DefragOptions | None = None) -> DefragStats: ... + # Key-rotation methods. Each raises InvalidEncryptionKeyError if the + # supplied existing/old/key does not unlock any slot. add_key and + # rotate_key raise NoFreeKeySlotError if the 8-slot table is full; + # remove_key raises LastKeySlotError if removing the key would leave + # the database with no active encryption key. + def add_key(self, existing: bytes | str, new: bytes | str) -> None: ... + def rotate_key(self, old: bytes | str, new: bytes | str) -> None: ... + def remove_key(self, key: bytes | str) -> None: ... + # Between-transaction config mutators. Each raises # TransactionInProgressError if called mid-transaction. def set_cache_max_bytes(self, bytes: int) -> None: ... diff --git a/python/src/db.rs b/python/src/db.rs index 727533a..ccb6c5a 100644 --- a/python/src/db.rs +++ b/python/src/db.rs @@ -49,6 +49,32 @@ use chisel::Chisel; use crate::errors::to_py_err; +/// Map a Python key argument to a `chisel::Key`. `bytes` → `Key::Raw` (any +/// length; the engine validates the length and raises BadKeyLength via +/// to_py_err if it is wrong). `str` → `Key::Passphrase`. Anything else raises +/// a Python `TypeError`. Key material is wrapped in `Zeroizing` immediately so +/// it is scrubbed when the `Key` is dropped; we never log or repr the value. +/// +/// Mirrors the `encryption_key` kwarg coercion in `open()` so the binding +/// has one key vocabulary. Factor is shared because add_key / rotate_key each +/// need two independent key values, and duplicating the coercion inline would +/// be both verbose and a maintenance hazard. +fn py_key(obj: &Bound<'_, PyAny>) -> PyResult { + if let Ok(b) = obj.cast::() { + Ok(chisel::Key::Raw(zeroize::Zeroizing::new( + b.as_bytes().to_vec(), + ))) + } else if let Ok(s) = obj.cast::() { + Ok(chisel::Key::Passphrase(zeroize::Zeroizing::new( + s.to_str()?.to_owned(), + ))) + } else { + Err(pyo3::exceptions::PyTypeError::new_err( + "key must be bytes (raw) or str (passphrase)", + )) + } +} + /// Convert a Python-supplied `u32` tag into a non-zero `chisel::Tag`, raising /// Python `ValueError` on `0`. Tag `0` is no longer a valid value — "untagged" /// is expressed by calling `allocate` (not `allocate_tagged`), and `tag()` @@ -509,6 +535,47 @@ impl PyChisel { Ok(cls.call((), Some(&kwargs))?.unbind()) } + // ── Key-rotation methods ───────────────────────────────────────── + // + // These three methods mirror `Chisel::{add_key, rotate_key, remove_key}` + // and use `py_key` (the module-level helper) to coerce each Python key + // argument. Key material is scrubbed (Zeroizing) immediately on coercion, + // before any Rust engine call, so keys never appear in error messages, + // tracebacks, or repr output. + // + // Errors are routed through the standard `to_py_err` path: + // InvalidEncryptionKey → InvalidEncryptionKeyError (wrong/unknown key) + // NoFreeKeySlot → NoFreeKeySlotError (8-slot table full) + // LastKeySlot → LastKeySlotError (would leave DB keyless) + // + // All three are between-transaction operations at the engine level; + // the engine enforces this and raises TransactionInProgress if violated. + + pub(crate) fn add_key( + &self, + existing: &Bound<'_, PyAny>, + new: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let existing = py_key(existing)?; + let new = py_key(new)?; + self.with_inner_mut_io(|c| c.add_key(&existing, &new)) + } + + pub(crate) fn rotate_key( + &self, + old: &Bound<'_, PyAny>, + new: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let old = py_key(old)?; + let new = py_key(new)?; + self.with_inner_mut_io(|c| c.rotate_key(&old, &new)) + } + + pub(crate) fn remove_key(&self, key: &Bound<'_, PyAny>) -> PyResult<()> { + let key = py_key(key)?; + self.with_inner_mut_io(|c| c.remove_key(&key)) + } + // ── Between-transaction configuration mutators ────────────────── // // The three setters below mirror the same-named methods on diff --git a/python/src/errors.rs b/python/src/errors.rs index b7a12a5..0ca94e0 100644 --- a/python/src/errors.rs +++ b/python/src/errors.rs @@ -35,6 +35,11 @@ // CacheFullError // SpillwayFullError // TransactionInProgressError +// NoEncryptionKeyError +// InvalidEncryptionKeyError +// EncryptionNotSupportedError +// NoFreeKeySlotError +// LastKeySlotError // ClosedError (I25: db.close() raced a live txn/sp) // AlreadyFinishedError (I22/I24: double-drive a finished txn/sp) // FatalError (drop-and-reopen recovery only) @@ -123,6 +128,10 @@ create_exception!(_chisel, NoEncryptionKeyError, OperationalError); create_exception!(_chisel, InvalidEncryptionKeyError, OperationalError); // EncryptionNotSupported: encryption_key was supplied but the DB is plaintext. create_exception!(_chisel, EncryptionNotSupportedError, OperationalError); +// NoFreeKeySlot: add_key/rotate_key attempted but the 8-slot key table is full. +create_exception!(_chisel, NoFreeKeySlotError, OperationalError); +// LastKeySlot: remove_key would leave the DB with no active key — rejected. +create_exception!(_chisel, LastKeySlotError, OperationalError); // ISSUES.md I25: raised by PyChisel's with_inner_io/with_inner_mut_io // helpers when `inner` has been cleared by a prior close(). Distinct // from PoisonedError because close() is a user action — the DB file @@ -220,6 +229,8 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { "EncryptionNotSupportedError", py.get_type::(), )?; + m.add("NoFreeKeySlotError", py.get_type::())?; + m.add("LastKeySlotError", py.get_type::())?; // IoError multiply-inherits (FatalError, OSError); register that class and // cache it so `to_py_err` constructs instances of it (not a single-base @@ -307,6 +318,10 @@ pub fn to_py_err(err: RustChiselError) -> PyErr { RustChiselError::NoEncryptionKey => NoEncryptionKeyError::new_err(msg), RustChiselError::InvalidEncryptionKey => InvalidEncryptionKeyError::new_err(msg), RustChiselError::EncryptionNotSupported => EncryptionNotSupportedError::new_err(msg), + // Key-rotation operational errors: the DB is intact; caller hit a + // capacity limit (table full) or a safety guard (last slot). + RustChiselError::NoFreeKeySlot => NoFreeKeySlotError::new_err(msg), + RustChiselError::LastKeySlot => LastKeySlotError::new_err(msg), // Fatal encryption: MAC verification failed on a page read after open. RustChiselError::DecryptionFailed { .. } => DecryptionFailedError::new_err(msg), // Fatal diff --git a/python/tests/test_encryption_keys.py b/python/tests/test_encryption_keys.py new file mode 100644 index 0000000..d2df6b2 --- /dev/null +++ b/python/tests/test_encryption_keys.py @@ -0,0 +1,181 @@ +"""Tests for add_key / rotate_key / remove_key on the Chisel binding. + +Covers: + - add_key: both keys unlock the DB after adding a second key + - add_key with a passphrase key works alongside a raw key + - rotate_key: old key is revoked, new key unlocks the same data + - remove_key: a removed key can no longer unlock the DB; the other can + - remove last key: LastKeySlotError (database must stay accessible) + - add_key to full table: NoFreeKeySlotError + - bad key type: TypeError from py_key coercion +""" + +import pathlib + +import chisel +import pytest + +K1 = bytes([0x11]) * 32 +K2 = bytes([0x22]) * 32 +K3 = bytes([0x33]) * 32 +# 10 distinct keys for the slot-full test (key envelope table holds 8). +_KEYS = [bytes([i]) * 32 for i in range(10)] + + +def _open(path: pathlib.Path, key: bytes | str) -> chisel.Chisel: + return chisel.open(path, encryption_key=key) + + +# --------------------------------------------------------------------------- +# add_key +# --------------------------------------------------------------------------- + +def test_add_key_either_key_opens(tmp_path: pathlib.Path): + """After add_key(K1, K2), the DB is openable with either K1 or K2.""" + path = tmp_path / "db" + + db = _open(path, K1) + db.begin() + h = db.allocate(b"secret-payload") + db.commit() + db.add_key(K1, K2) + db.close() + + with _open(path, K1) as db1: + assert db1.read(h) == b"secret-payload" + with _open(path, K2) as db2: + assert db2.read(h) == b"secret-payload" + + +def test_add_key_passphrase_alongside_raw(tmp_path: pathlib.Path): + """A passphrase key can be added alongside an existing raw key.""" + path = tmp_path / "db" + passphrase = "correct horse battery staple" + + with _open(path, K1) as db: + db.begin() + h = db.allocate(b"v") + db.commit() + db.add_key(K1, passphrase) + + with _open(path, passphrase) as db: + assert db.read(h) == b"v" + + with _open(path, K1) as db: + assert db.read(h) == b"v" + + +# --------------------------------------------------------------------------- +# rotate_key +# --------------------------------------------------------------------------- + +def test_rotate_key_old_revoked(tmp_path: pathlib.Path): + """After rotate_key(K1, K2), K1 is revoked and K2 opens the same data.""" + path = tmp_path / "db" + + with _open(path, K1) as db: + db.begin() + h = db.allocate(b"rotated-data") + db.commit() + db.rotate_key(K1, K2) + + with pytest.raises(chisel.InvalidEncryptionKeyError): + chisel.open(str(path), create_if_missing=False, encryption_key=K1) + + with _open(path, K2) as db: + assert db.read(h) == b"rotated-data" + + +def test_rotate_key_with_two_existing_slots(tmp_path: pathlib.Path): + """rotate_key revokes the named old slot even when a second slot exists.""" + path = tmp_path / "db" + + with _open(path, K1) as db: + db.begin() + h = db.allocate(b"multi-slot") + db.commit() + db.add_key(K1, K2) # two slots: K1 and K2 + db.rotate_key(K1, K3) # replace K1 slot with K3; K2 slot unchanged + + with pytest.raises(chisel.InvalidEncryptionKeyError): + chisel.open(str(path), create_if_missing=False, encryption_key=K1) + + with _open(path, K2) as db: + assert db.read(h) == b"multi-slot" + + with _open(path, K3) as db: + assert db.read(h) == b"multi-slot" + + +# --------------------------------------------------------------------------- +# remove_key +# --------------------------------------------------------------------------- + +def test_remove_key_revokes_that_slot(tmp_path: pathlib.Path): + """After remove_key(K1) with K2 still active, K1 is rejected.""" + path = tmp_path / "db" + + with _open(path, K1) as db: + db.begin() + h = db.allocate(b"remove-test") + db.commit() + db.add_key(K1, K2) + db.remove_key(K1) + + with pytest.raises(chisel.InvalidEncryptionKeyError): + chisel.open(str(path), create_if_missing=False, encryption_key=K1) + + with _open(path, K2) as db: + assert db.read(h) == b"remove-test" + + +def test_remove_last_key_raises_last_key_slot(tmp_path: pathlib.Path): + """remove_key on the only active key must raise LastKeySlotError.""" + path = tmp_path / "db" + + with _open(path, K1) as db: + db.begin() + db.allocate(b"x") + db.commit() + with pytest.raises(chisel.LastKeySlotError): + db.remove_key(K1) + + +# --------------------------------------------------------------------------- +# add_key to a full key table +# --------------------------------------------------------------------------- + +def test_add_key_full_table_raises_no_free_key_slot(tmp_path: pathlib.Path): + """add_key when all 8 key slots are occupied must raise NoFreeKeySlotError. + + The key envelope table has 8 fixed slots. Open with _KEYS[0] (1 slot), + add keys 1–7 (fills the remaining 7 slots → all 8 occupied), then + attempt one more add_key. + """ + path = tmp_path / "db" + + with chisel.open(str(path), encryption_key=_KEYS[0]) as db: + for i in range(1, 8): + db.add_key(_KEYS[i - 1], _KEYS[i]) # fill slots 2..8 + with pytest.raises(chisel.NoFreeKeySlotError): + db.add_key(_KEYS[7], _KEYS[8]) # 9th key — table full + + +# --------------------------------------------------------------------------- +# bad key type +# --------------------------------------------------------------------------- + +def test_bad_key_type_raises_type_error(tmp_path: pathlib.Path): + """Passing a non-bytes, non-str key raises TypeError immediately.""" + path = tmp_path / "db" + with _open(path, K1) as db: + db.begin() + db.allocate(b"x") + db.commit() + db.add_key(K1, K2) # ensure two slots exist so remove_key is safe + with pytest.raises(TypeError): + db.add_key(K1, 12345) + with pytest.raises(TypeError): + db.rotate_key(K1, 12345) + with pytest.raises(TypeError): + db.remove_key(12345) diff --git a/python/tests/test_exception_contract.py b/python/tests/test_exception_contract.py index c295f49..d3d46b2 100644 --- a/python/tests/test_exception_contract.py +++ b/python/tests/test_exception_contract.py @@ -415,3 +415,45 @@ def test_decryption_failed_is_fatal_hierarchy(): assert issubclass(chisel.DecryptionFailedError, chisel.FatalError) assert issubclass(chisel.DecryptionFailedError, chisel.ChiselError) assert not issubclass(chisel.DecryptionFailedError, chisel.OperationalError) + + +# --------------------------------------------------------------------------- +# 13. Key-rotation exception contract (Phase 5, Task 5.5) +# --------------------------------------------------------------------------- +# Two ChiselError key-rotation variants map to typed Python classes: +# +# NoFreeKeySlot -> NoFreeKeySlotError (OperationalError) +# LastKeySlot -> LastKeySlotError (OperationalError) +# +# NoFreeKeySlotError is triggered end-to-end: fill all 8 key slots (add_key +# 7 times after the initial open) then attempt an 8th add_key. The engine +# has a fixed 8-slot key envelope table; the 9th call must raise exactly +# NoFreeKeySlotError. +# +# LastKeySlotError is triggered end-to-end: open with one key, call +# remove_key — the engine refuses because removing the last slot would +# leave the database permanently inaccessible. + +_KS = [bytes([i]) * 32 for i in range(10)] # 10 distinct 32-byte raw keys + + +def test_no_free_key_slot_exact_class(tmp_db): + # The key envelope table holds 8 slots. Create a DB with key 0 (1 slot + # used). Add keys 1–7 (slots 2–8 used, table full). Then add key 8 — the + # table is full and NoFreeKeySlotError must be raised. + with chisel.open(str(tmp_db), encryption_key=_KS[0]) as db: + for i in range(1, 8): # add keys 1..7 → 8 slots occupied + db.add_key(_KS[i - 1], _KS[i]) + with pytest.raises(chisel.NoFreeKeySlotError) as exc_info: + db.add_key(_KS[7], _KS[8]) # 9th key — no slot available + assert isinstance(exc_info.value, chisel.OperationalError) + + +def test_last_key_slot_exact_class(tmp_db): + # A DB opened with a single key has exactly one occupied slot. Removing + # that key would leave no way to unlock the database; LastKeySlotError + # must fire. + with chisel.open(str(tmp_db), encryption_key=_KS[0]) as db: + with pytest.raises(chisel.LastKeySlotError) as exc_info: + db.remove_key(_KS[0]) + assert isinstance(exc_info.value, chisel.OperationalError) From 421c1aca37bd2b3c5a429c41cdd662955ad7845d Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 20:06:18 -0700 Subject: [PATCH 33/42] feat(format): add encrypted-DB MAJOR version constant and gate-rejection test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib.rs | 5 ++- src/page.rs | 13 ++++++++ src/transaction/recovery.rs | 64 +++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 5ee4406..556be1c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,7 +78,10 @@ pub use superblock::{ CRYPTO_HEADER_OFFSET, DEFAULT_SUPERBLOCK_COUNT, KEY_SLOT_COUNT, KEY_SLOT_SIZE, MAX_SUPERBLOCKS, MIN_SUPERBLOCKS, NAMED_ROOT_COUNT, NAMED_ROOT_NAME_LEN, }; -pub use page::{FORMAT_MAJOR_VERSION_ENCRYPTED, format_major, format_version_encrypted}; +pub use page::{ + ENCRYPTED_FORMAT_VERSION, FORMAT_MAJOR_VERSION_ENCRYPTED, FORMAT_MINOR_VERSION_ENCRYPTED, + format_major, format_version_encrypted, +}; pub use crypto::{ derive_kek, unwrap_dek, wrap_dek, Argon2Params, KdfId, Key, PageCipher, CryptoError, NONCE_LEN, SALT_LEN, DEK_LEN, diff --git a/src/page.rs b/src/page.rs index 7515077..69e414d 100644 --- a/src/page.rs +++ b/src/page.rs @@ -117,6 +117,19 @@ pub const FORMAT_MINOR_VERSION: u16 = 1; /// 1 → 2 hard-rejects old binaries (which gate on FORMAT_MAJOR_VERSION == 1). pub const FORMAT_MAJOR_VERSION_ENCRYPTED: u16 = 2; +/// MINOR version for the encrypted-DB format series. Starts at 0 (first +/// encrypted release). Encrypted DBs carry their own minor series: a minor bump +/// here signals an additive change inside an encrypted DB's superblock fields, +/// just as `FORMAT_MINOR_VERSION` does for plaintext DBs. +pub const FORMAT_MINOR_VERSION_ENCRYPTED: u16 = 0; + +/// Packed format_version stamped into every ENCRYPTED database's superblock: +/// `pack_format_version(FORMAT_MAJOR_VERSION_ENCRYPTED, FORMAT_MINOR_VERSION_ENCRYPTED)`. +/// An encryption-unaware binary (FORMAT_MAJOR_VERSION == 1) rejects this as +/// `UnsupportedFormatVersion`, which is the intended hard-reject behaviour. +pub const ENCRYPTED_FORMAT_VERSION: u32 = + pack_format_version(FORMAT_MAJOR_VERSION_ENCRYPTED, FORMAT_MINOR_VERSION_ENCRYPTED); + /// Pack the encrypted-DB format version: MAJOR=2, MINOR=current. Used by /// `create_new` when a key is supplied, and by Task 2.4's open-time gate. pub fn format_version_encrypted() -> u32 { diff --git a/src/transaction/recovery.rs b/src/transaction/recovery.rs index 809eadd..abde359 100644 --- a/src/transaction/recovery.rs +++ b/src/transaction/recovery.rs @@ -644,3 +644,67 @@ fn unwrap_first_matching_slot( } Err(ChiselError::InvalidEncryptionKey) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::page::{self, ENCRYPTED_FORMAT_VERSION, FORMAT_VERSION, PAGE_SIZE}; + use crate::superblock::Superblock; + + /// Prove that an encryption-unaware open (no key, no crypto header + /// expected) hard-rejects a MAJOR=2 superblock with + /// `UnsupportedFormatVersion`. This is the gate-rejection guarantee: an + /// old binary (FORMAT_MAJOR_VERSION == 1) can never accidentally read an + /// encrypted DB's 8232-byte-stride data as plaintext 8192-byte pages. + /// + /// The mechanism: `open_existing` computes `expected_major = if + /// sb.encryption.is_some() { 2 } else { 1 }`. A MAJOR=2 file with no + /// crypto header has `sb.encryption = None`, so `expected_major = 1`, but + /// `format_major(sb.format_version) = 2` — mismatch → error. + #[test] + fn encrypted_major_version_is_rejected_by_plaintext_binary() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("enc_major.chsl"); + + // Build a valid plaintext superblock (superblock_count=2 for the + // default layout), then overwrite its format_version with the + // encrypted-DB MAJOR=2 value. Everything else is a normal empty DB — + // in particular, `sb.encryption` stays None, so the gate's + // `expected_major` resolves to 1 (plaintext), not 2. + let mut sb = Superblock::new_empty(2); + sb.format_version = ENCRYPTED_FORMAT_VERSION; // pack(2, 0) + assert_eq!( + page::format_major(sb.format_version), + 2, + "sanity: ENCRYPTED_FORMAT_VERSION must have MAJOR=2" + ); + + // Write the superblock as page 0 at plaintext stride (the format- + // version gate fires before stride-dependent reads; page 0 is always + // at offset 0). + let bytes = sb.serialize(); + assert_eq!(bytes.len(), PAGE_SIZE); + std::fs::write(&path, bytes).unwrap(); + + // Open without a key: simulates an encryption-unaware binary. + // Must be rejected with UnsupportedFormatVersion. + let err = crate::Chisel::open(&path, crate::Options::default()) + .err() + .expect("MAJOR=2 file opened without a key must fail"); + match err { + ChiselError::UnsupportedFormatVersion { found, expected } => { + assert_eq!( + page::format_major(found), + 2, + "error must report the MAJOR=2 version found on disk" + ); + assert_eq!( + expected, + FORMAT_VERSION, // pack(1, 1) — what a plaintext binary expects + "error must report FORMAT_VERSION as the expected version for a no-key open" + ); + } + other => panic!("expected UnsupportedFormatVersion, got {other:?}"), + } + } +} From 8c0890a0d09c36cf47b2d2025e2cd94874421e1e Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 20:13:58 -0700 Subject: [PATCH 34/42] refactor(format): make format_version_encrypted the single source for 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). --- src/lib.rs | 5 +---- src/page.rs | 31 +++++++++++++++++-------------- src/transaction/recovery.rs | 8 +++++--- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 556be1c..5ee4406 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,10 +78,7 @@ pub use superblock::{ CRYPTO_HEADER_OFFSET, DEFAULT_SUPERBLOCK_COUNT, KEY_SLOT_COUNT, KEY_SLOT_SIZE, MAX_SUPERBLOCKS, MIN_SUPERBLOCKS, NAMED_ROOT_COUNT, NAMED_ROOT_NAME_LEN, }; -pub use page::{ - ENCRYPTED_FORMAT_VERSION, FORMAT_MAJOR_VERSION_ENCRYPTED, FORMAT_MINOR_VERSION_ENCRYPTED, - format_major, format_version_encrypted, -}; +pub use page::{FORMAT_MAJOR_VERSION_ENCRYPTED, format_major, format_version_encrypted}; pub use crypto::{ derive_kek, unwrap_dek, wrap_dek, Argon2Params, KdfId, Key, PageCipher, CryptoError, NONCE_LEN, SALT_LEN, DEK_LEN, diff --git a/src/page.rs b/src/page.rs index 69e414d..5bdc816 100644 --- a/src/page.rs +++ b/src/page.rs @@ -117,23 +117,26 @@ pub const FORMAT_MINOR_VERSION: u16 = 1; /// 1 → 2 hard-rejects old binaries (which gate on FORMAT_MAJOR_VERSION == 1). pub const FORMAT_MAJOR_VERSION_ENCRYPTED: u16 = 2; -/// MINOR version for the encrypted-DB format series. Starts at 0 (first -/// encrypted release). Encrypted DBs carry their own minor series: a minor bump -/// here signals an additive change inside an encrypted DB's superblock fields, -/// just as `FORMAT_MINOR_VERSION` does for plaintext DBs. -pub const FORMAT_MINOR_VERSION_ENCRYPTED: u16 = 0; - -/// Packed format_version stamped into every ENCRYPTED database's superblock: -/// `pack_format_version(FORMAT_MAJOR_VERSION_ENCRYPTED, FORMAT_MINOR_VERSION_ENCRYPTED)`. -/// An encryption-unaware binary (FORMAT_MAJOR_VERSION == 1) rejects this as -/// `UnsupportedFormatVersion`, which is the intended hard-reject behaviour. -pub const ENCRYPTED_FORMAT_VERSION: u32 = +/// MINOR version for the encrypted-DB format series. A new MAJOR series starts +/// its MINOR count at 0, so encrypted DBs stamp (2, 0) — NOT (2, FORMAT_MINOR_VERSION). +/// The encrypted format carries its own minor series, independent of plaintext. +pub(crate) const FORMAT_MINOR_VERSION_ENCRYPTED: u16 = 0; + +/// The SINGLE canonical packed format_version for an ENCRYPTED database's +/// superblock: `pack(2, 0)`. `format_version_encrypted()` returns this constant; +/// create, open, and the superblock-body AAD (`sb_identity_aad`) all route +/// through that function, so there is exactly ONE on-disk value — no two +/// constants that can drift. An encryption-unaware binary (FORMAT_MAJOR_VERSION +/// == 1) rejects it as `UnsupportedFormatVersion`, the intended hard-reject. +pub(crate) const ENCRYPTED_FORMAT_VERSION: u32 = pack_format_version(FORMAT_MAJOR_VERSION_ENCRYPTED, FORMAT_MINOR_VERSION_ENCRYPTED); -/// Pack the encrypted-DB format version: MAJOR=2, MINOR=current. Used by -/// `create_new` when a key is supplied, and by Task 2.4's open-time gate. +/// The encrypted-DB packed format version (MAJOR=2, MINOR=0). Single source of +/// truth: returns `ENCRYPTED_FORMAT_VERSION`. Called by the create path +/// (keys.rs, superblock::new_empty_encrypted), the commit stamp, and the +/// open-time gate — they all agree because they all call this one function. pub fn format_version_encrypted() -> u32 { - pack_format_version(FORMAT_MAJOR_VERSION_ENCRYPTED, FORMAT_MINOR_VERSION) + ENCRYPTED_FORMAT_VERSION } /// Pack a (major, minor) pair into the on-disk u32 format version. diff --git a/src/transaction/recovery.rs b/src/transaction/recovery.rs index abde359..53fea1e 100644 --- a/src/transaction/recovery.rs +++ b/src/transaction/recovery.rs @@ -693,11 +693,13 @@ mod tests { .expect("MAJOR=2 file opened without a key must fail"); match err { ChiselError::UnsupportedFormatVersion { found, expected } => { + // The exact stamped value is pack(2, 0) = 0x0002_0000 — the + // single canonical encrypted format version. MAJOR byte is 2. assert_eq!( - page::format_major(found), - 2, - "error must report the MAJOR=2 version found on disk" + found, ENCRYPTED_FORMAT_VERSION, + "error must report the exact MAJOR=2 MINOR=0 version found on disk" ); + assert_eq!(page::format_major(found), 2); assert_eq!( expected, FORMAT_VERSION, // pack(1, 1) — what a plaintext binary expects From 15d2b3efc2acc3a216a69a29a0dcf95de6ce8814 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 20:20:25 -0700 Subject: [PATCH 35/42] docs: document on-disk encryption architecture and defer bulk DEK rotation (I142) --- ARCHITECTURE.md | 32 ++++++++++++++++++++++++++++++++ ISSUES.md | 20 ++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cbce236..f82f7dd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,6 +13,7 @@ This is a living document; update it when the architecture changes. Decisions do 5. [Recovery on open](#recovery-on-open) 6. [On-disk format](#on-disk-format) 7. [Cross-cutting concepts](#cross-cutting-concepts) + - [On-disk encryption](#on-disk-encryption) 8. [Benchmark infrastructure](#benchmark-infrastructure) 9. [Implementation history](#implementation-history) 10. [Glossary](#glossary) @@ -615,6 +616,37 @@ Chisel versions its on-disk format at two levels. Both schemes leave reserved space for forward compatibility — the superblock has bytes 324..8184 reserved (after the `freemap_depth` field at 320..324), and every non-superblock page has bytes 8..16 reserved (8 bytes / 64 bits) for future common-header fields. +### On-disk encryption + +Chisel supports optional authenticated encryption of database files. An encrypted database is indistinguishable from random bytes to a reader without the key; each page is individually authenticated, so corruption (accidental or deliberate) is detected before any plaintext is returned. + +**Cipher.** XChaCha20-Poly1305 (IETF extended-nonce variant). Each page write generates a fresh random 192-bit nonce; the extended nonce space (2¹⁹²) makes nonce reuse under shadow-paging page reassignment negligible in practice (spec §2.1). The on-disk layout per encrypted page is `ciphertext(8192) ‖ tag(16) ‖ nonce(24)` = 8232 bytes (`ENC_PAGE_SIZE`). The additional data (AAD) for each page is the 8-byte little-endian `page_id`, which binds ciphertext to its slot and prevents a valid block from being relocated to another page position without detection. + +**On-disk stride.** Encrypted databases use a uniform 8232-byte stride for every page including the superblock slots. Plaintext databases continue to use the 8192-byte stride; the two are mutually exclusive and the stride is recorded in the superblock's plaintext crypto-header so the engine reads the correct number of bytes before attempting any operation. The `page_io` layer is stride-agnostic: callers set the stride once (via `PageIo::set_stride`) and all subsequent raw reads and writes use it. + +**Envelope (key hierarchy).** A random 256-bit per-database encryption key (DEK) encrypts all page content. The DEK itself is never stored in plaintext: it is wrapped under a key-encryption key (KEK) and the wrapped form is held in a plaintext key-slot table inside the superblock's reserved region (bytes 324..1356; 8 slots × 128 bytes each, preceded by a 1-byte algorithm id and 4-byte stride field at byte 324). Each slot stores the KDF identity, KDF parameters, salt, wrap nonce, wrapped DEK, and wrap tag. There are two KEK derivation paths: + +- **Raw key** (`Key::Raw`): KEK = HKDF-SHA256(ikm=key material, salt=slot salt, info=`"chisel-kek"`). +- **Passphrase** (`Key::Passphrase`): KEK = Argon2id(password, salt, m/t/p from the slot's stored parameters). + +The DEK wrapping uses detached XChaCha20-Poly1305 with AAD bound to the slot's KDF metadata, so an attacker cannot swap a slot's KDF parameters to force mis-derivation without breaking the tag. + +**Superblock body protection.** The superblock's sensitive body — root pointers (`root_handle_table`, `root_freemap_page`, `root_tag_map_page`), `total_pages`, `next_handle`, `freemap_depth`, and the `named_roots` name table — is sealed under the DEK as a `nonce ‖ tag ‖ ciphertext` sub-blob whose AAD binds it to the superblock's identity. The plaintext portion of the superblock (magic, format version, txn counter, page size, superblock count, crypto-header) retains its XXH3 checksum so the A/B torn-write selector (`select()`) still works before any decryption. + +**Format version.** Encrypted databases stamp file-level **MAJOR = 2, MINOR = 0**. Plaintext databases remain at MAJOR = 1. The existing open-time gate (which rejects any file whose MAJOR differs from the compiled-in `FORMAT_MAJOR_VERSION`) therefore hard-rejects an encrypted database on an encryption-unaware binary with `UnsupportedFormatVersion`, preventing ciphertext from being silently misread as page data. No per-page (I31) format change is needed — the logical page image is unchanged. + +**Key management.** Credential rotation is O(1) and crash-safe — it never re-encrypts any page: + +- `add_key(old_key, new_key)`: derives a new KEK, wraps the same DEK into a free slot, then commits. +- `rotate_key(old_key, new_key)`: `add_key` followed by clearing the old slot in the same commit. +- `remove_key(key)`: clears the matching slot, refusing to clear the last active slot (which would make the database permanently unreadable). + +Each operation is a normal superblock commit through the A/B + fsync protocol. Bulk DEK rotation (re-encrypting every page under a fresh DEK — relevant only when the DEK itself is believed compromised) is deferred; see I142. + +**Spillway.** For encrypted databases the in-memory spillway carries sealed blobs: pages are encrypted exactly once on eviction from the page cache (`seal` on evict-to-spillway) and copied verbatim — without decryption or re-encryption — on drain to the main file. Rehydration from the spillway decrypts the blob back into the cache. This means no plaintext page content is ever written to disk by an encrypted database, even during spill. + +**Threat-model boundary.** Provided: confidentiality of all user data and sensitive metadata at rest; AEAD tamper-detection per page and per superblock body (any modification surfaces as the fatal `DecryptionFailed` error, which poisons the engine); anti-relocation (AAD = `page_id` prevents transplanting a ciphertext block to a different slot). Not provided: rollback/replay resistance (an attacker who substitutes a wholly older, validly-signed database image cannot be detected without an external monotonic trust anchor such as a TPM); in-memory protection beyond `zeroize`-on-drop for the DEK and page plaintext; traffic-analysis resistance (file size, page count, and access patterns are visible). + --- ## Benchmark infrastructure diff --git a/ISSUES.md b/ISSUES.md index a6adb58..854b628 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -1705,3 +1705,23 @@ Suggested order for this cluster: **I108** (CI lint hole) and **I111** (radix pr **Disposition (decomposed, then deliberately stopped):** Worked incrementally — a directory-module split by concern plus four extracted units: `SlotPacker` (R1 packing, #77), `FreemapRecycle` (structural recycle + persist/reclaim, #78), `CommitProtocol` (the 3-fsync sequence, #79), and a `#[cfg(test)] FaultInjector` (#76). All behavior-preserving (the existing suite is the oracle; FreemapRecycle additionally passed a 4-lens adversarial review). The planned final unit — `StagingTxn` (the BUG#2 atomic `allocate_inner` prepare/install) — was **deliberately NOT extracted**: the candidate-prepare/install vocabulary (`handle_table_insert_candidate`, `membership_insert_candidate`, `membership_remove_candidate`, `abort_allocate_prepare`, `inject_membership_failure`, `insert_into_data_page`) is **shared across `allocate_inner` (staging.rs) AND `update_inner`/`delete_inner` (mutate.rs)**, so a context-based extraction cannot be contained to staging.rs — it would force the most delicate mutation paths + packing.rs through a mechanical wrapper change for little cohesion gain. `staging.rs` is already a focused, holdable-in-context concern file. **Future work (incremental only):** if/when the staging paths are touched for a real reason, fold the shared vocabulary into a `StagingTxn` unit at that point. Do NOT re-trigger a standalone extraction from the SMELL alone — the engine passes every test; on a green, pre-production engine a below-bug structural finding is the lowest-value, highest-risk change (this whole decomposition was a large undertaking driven by one SMELL). The remaining `transaction.rs` SMELL items below the extraction (the test-only flags) are already addressed by the `FaultInjector` split. + +--- + +## On-disk encryption + +Source: **[encryption 2026-06-29]** — deferred work captured while implementing the on-disk encryption feature (design at `docs/specs/2026-06-29-on-disk-encryption-design.md`). + +#### I142. Bulk DEK rotation (full re-encryption under a new DEK) is not implemented — **P3** ⏸ DEFERRED 2026-06-29 + +**Where:** `src/lib.rs`, `src/transaction/keys.rs`, `src/crypto/mod.rs` + +**Problem:** There are two distinct "key rotation" operations with very different costs: + +1. **Credential rotation** (KEK re-wrap): derives a new KEK from a new passphrase or raw key and re-wraps the existing DEK into a key slot. This is O(1) — it touches only the superblock — and is fully implemented via `add_key` / `rotate_key` / `remove_key`. + +2. **Bulk DEK rotation** (full re-encryption): generates a fresh DEK, re-encrypts every data page under the new DEK, and replaces the wrapped DEK in all active key slots. This is O(total\_pages) — it must rewrite the entire database file — and is **not yet implemented**. + +Bulk DEK rotation is only needed when the DEK itself is believed compromised (e.g., a process memory dump exposed the in-session DEK). Credential rotation — the far more common operational need (password change, key rollover, adding a second credential) — is already available and O(1). Because no production databases exist today and the DEK is not separately distributed, the risk of DEK compromise is low; the heavy whole-file cost makes this a poor default rotation path. + +**Direction of fix (when needed):** Implement a `rekey(old_key, new_key)` or `rekey_dek(key)` API that (1) generates a fresh DEK, (2) reads, decrypts, re-encrypts, and writes back every non-superblock page in a single pass using the existing stride-aware `PageIo`, (3) re-wraps the new DEK into all currently-active key slots, and (4) commits an updated superblock. The operation must be crash-safe: either complete or leave the original file intact. A copy-then-atomic-rename strategy (write the new file alongside, then rename) is the simplest crash-safe approach for an embedded store; an in-place two-pass strategy is also possible but more complex. Reuse `PageCipher::seal` / `PageCipher::open` and `CryptoHeader` from the existing crypto layer. From 00a5b93f80460ff1244bea8abda789c3d3ea2927 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 20:38:00 -0700 Subject: [PATCH 36/42] refactor(api): scope internal crypto/format/superblock items to pub(crate) 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). --- src/lib.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 5ee4406..f6bcb0e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,16 +73,17 @@ pub use defrag::{DefragOptions, DefragStats}; pub use handle::{Handle, Tag, TagDropProgress}; pub use page::PAGE_SIZE; pub use stats::{ChiselCounters, Stats}; +// SlotDefect and SuperblockDefect were public before this branch (pre-existing API). pub use superblock::{ - CryptoHeader, KeySlot, SlotDefect, SuperblockDefect, ALGO_XCHACHA20POLY1305, - CRYPTO_HEADER_OFFSET, DEFAULT_SUPERBLOCK_COUNT, KEY_SLOT_COUNT, KEY_SLOT_SIZE, - MAX_SUPERBLOCKS, MIN_SUPERBLOCKS, NAMED_ROOT_COUNT, NAMED_ROOT_NAME_LEN, -}; -pub use page::{FORMAT_MAJOR_VERSION_ENCRYPTED, format_major, format_version_encrypted}; -pub use crypto::{ - derive_kek, unwrap_dek, wrap_dek, Argon2Params, KdfId, Key, PageCipher, CryptoError, - NONCE_LEN, SALT_LEN, DEK_LEN, + SlotDefect, SuperblockDefect, DEFAULT_SUPERBLOCK_COUNT, MAX_SUPERBLOCKS, MIN_SUPERBLOCKS, + NAMED_ROOT_COUNT, NAMED_ROOT_NAME_LEN, }; +// format_major was public before this branch (I29 read-dispatch). +pub use page::format_major; +// Key and Argon2Params are public API (callers need them to open encrypted DBs). +// Crypto internals (PageCipher, CryptoError, raw constants) are pub(crate) in +// their source modules and not re-exported here. +pub use crypto::{Argon2Params, Key}; use std::path::Path; From bbdabfa82c2a0182b4217094d016f6c3db692e92 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 20:40:37 -0700 Subject: [PATCH 37/42] fix(crypto): return typed errors instead of panicking on bad input/corruption 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. --- src/page_cache.rs | 8 ++++---- src/superblock/crypto_header.rs | 23 +++++++++++++---------- src/transaction/keys.rs | 8 ++++---- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/page_cache.rs b/src/page_cache.rs index 7146660..4f43f23 100644 --- a/src/page_cache.rs +++ b/src/page_cache.rs @@ -509,7 +509,7 @@ impl PageCache { let unit: [u8; ENC_PAGE_SIZE] = blob .as_slice() .try_into() - .expect("spillway rehydrate returned wrong length for encrypted blob"); + .map_err(|_| ChiselError::DecryptionFailed { page_id })?; self.io.write_page_unit(page_id, &unit)?; let pt = c .open(page_id, &unit) @@ -520,7 +520,7 @@ impl PageCache { // blob is PAGE_SIZE bytes; write directly. let pt: [u8; page::PAGE_SIZE] = blob .try_into() - .expect("spillway rehydrate returned wrong length for plaintext blob"); + .map_err(|_| ChiselError::DecryptionFailed { page_id })?; self.io.write_page_unit(page_id, &pt)?; Box::new(pt) } @@ -959,7 +959,7 @@ impl PageCache { let unit: [u8; ENC_PAGE_SIZE] = blob .as_slice() .try_into() - .expect("spillway rehydrate returned wrong length for encrypted blob"); + .map_err(|_| ChiselError::DecryptionFailed { page_id })?; let pt = c .open(page_id, &unit) .map_err(|_| ChiselError::DecryptionFailed { page_id })?; @@ -967,7 +967,7 @@ impl PageCache { } None => Box::new( blob.try_into() - .expect("spillway rehydrate returned wrong length for plaintext blob"), + .map_err(|_| ChiselError::DecryptionFailed { page_id })?, ), }; self.entries.insert( diff --git a/src/superblock/crypto_header.rs b/src/superblock/crypto_header.rs index edea3bf..9eaef46 100644 --- a/src/superblock/crypto_header.rs +++ b/src/superblock/crypto_header.rs @@ -29,6 +29,8 @@ use crate::page::{self, PAGE_SIZE}; pub const KEY_SLOT_COUNT: usize = 8; pub const KEY_SLOT_SIZE: usize = 128; +/// State byte value for an occupied, usable slot. +const KEY_SLOT_ACTIVE: u8 = 1; // Immediately after freemap_depth (bytes 320..324). Keep in lockstep with // superblock/mod.rs's FREEMAP_DEPTH_OFFSET (320) + 4. pub const CRYPTO_HEADER_OFFSET: usize = 324; @@ -64,9 +66,9 @@ impl KeySlot { wrap_tag: [0u8; TAG_LEN], }; - /// True if this slot holds a usable wrapped DEK (state byte == 1). + /// True if this slot holds a usable wrapped DEK (state byte == `KEY_SLOT_ACTIVE`). pub fn is_active(&self) -> bool { - self.state == 1 + self.state == KEY_SLOT_ACTIVE } /// The bytes an unwrap operation must authenticate as AAD: the slot's own @@ -228,7 +230,7 @@ impl CryptoHeader { slot: usize, key: &crate::crypto::Key, dek: &crate::crypto::Dek, - ) { + ) -> Result<(), crate::crypto::CryptoError> { use crate::crypto::{self, KdfId}; let (kdf_id, argon2) = match key { crate::crypto::Key::Raw(_) => { @@ -239,7 +241,7 @@ impl CryptoHeader { let salt: [u8; SALT_LEN] = crypto::random_array(); let wrap_nonce: [u8; NONCE_LEN] = crypto::random_array(); let mut s = KeySlot { - state: 1, // active + state: KEY_SLOT_ACTIVE, kdf_id: kdf_id as u8, argon2, salt, @@ -252,12 +254,12 @@ impl CryptoHeader { // wrapped_dek/wrap_tag. This ordering matches unlock() and the // existing recovery.rs path — all three call slot.aad() on the // populated-but-pre-wrap slot. - let kek = crypto::derive_kek(key, kdf_id, &s.salt, &s.argon2) - .expect("fresh random salt cannot trigger a KDF parameter error"); + let kek = crypto::derive_kek(key, kdf_id, &s.salt, &s.argon2)?; let (wrapped, tag) = crypto::wrap_dek(&kek, dek, &s.wrap_nonce, &s.aad()); s.wrapped_dek = wrapped; s.wrap_tag = tag; self.slots[slot] = s; + Ok(()) } } @@ -278,7 +280,7 @@ mod crypto_header_tests { stride: crypto::ENC_PAGE_SIZE as u32, slots: [KeySlot::EMPTY; KEY_SLOT_COUNT], }; - h.wrap_into(0, key, dek); + h.wrap_into(0, key, dek).expect("wrap_into with valid key must succeed"); h } @@ -290,7 +292,7 @@ mod crypto_header_tests { // Add a second credential into slot 3 wrapping the SAME dek. let k1 = raw(0xB2); - h.wrap_into(3, &k1, &dek); + h.wrap_into(3, &k1, &dek).expect("wrap_into with valid key must succeed"); let (idx0, d0) = h.unlock(&k0).expect("k0 must unlock"); let (idx1, d1) = h.unlock(&k1).expect("k1 must unlock"); @@ -329,7 +331,8 @@ mod crypto_header_tests { // Fill every remaining slot. for i in 1..KEY_SLOT_COUNT { - h.wrap_into(i, &raw(i as u8 + 1), &dek); + h.wrap_into(i, &raw(i as u8 + 1), &dek) + .expect("wrap_into with valid key must succeed"); } assert_eq!(h.active_count(), KEY_SLOT_COUNT); assert_eq!(h.free_slot(), None); @@ -346,7 +349,7 @@ mod crypto_header_tests { stride: crypto::ENC_PAGE_SIZE as u32, slots: [KeySlot::EMPTY; KEY_SLOT_COUNT], }; - h.wrap_into(5, &key, &dek); + h.wrap_into(5, &key, &dek).expect("wrap_into with valid key must succeed"); let (idx, recovered) = h.unlock(&key).expect("wrap_into then unlock must succeed"); assert_eq!(idx, 5); assert_eq!(recovered.as_bytes(), dek.as_bytes()); diff --git a/src/transaction/keys.rs b/src/transaction/keys.rs index 2367327..e3fa3ab 100644 --- a/src/transaction/keys.rs +++ b/src/transaction/keys.rs @@ -139,7 +139,7 @@ impl TransactionManager { let (_idx, dek) = header.unlock(existing)?; // → InvalidEncryptionKey if none let free = header.free_slot().ok_or(ChiselError::NoFreeKeySlot)?; let mut new_header = *header; - new_header.wrap_into(free, new, &dek); + new_header.wrap_into(free, new, &dek).map_err(|_| ChiselError::InvalidEncryptionKey)?; self.rewrite_crypto_header(new_header) } @@ -160,7 +160,7 @@ impl TransactionManager { let (old_idx, dek) = header.unlock(old)?; // → InvalidEncryptionKey if none let free = header.free_slot().ok_or(ChiselError::NoFreeKeySlot)?; let mut new_header = *header; - new_header.wrap_into(free, new, &dek); + new_header.wrap_into(free, new, &dek).map_err(|_| ChiselError::InvalidEncryptionKey)?; // Clear the old slot in the same header snapshot — single atomic rewrite. new_header.slots[old_idx] = crate::superblock::KeySlot::EMPTY; self.rewrite_crypto_header(new_header) @@ -302,7 +302,7 @@ mod tests { // Unlock slot 0 to get the DEK, then wrap it into a second slot. let mut new_hdr = db.crypto_header.expect("encrypted DB must have crypto_header"); let (_, dek) = new_hdr.unlock(&raw(0x11)).expect("slot 0 unlocks with key 0x11"); - new_hdr.wrap_into(1, &raw(0x22), &dek); + new_hdr.wrap_into(1, &raw(0x22), &dek).expect("wrap_into with valid key must succeed"); db.rewrite_crypto_header(new_hdr).unwrap(); @@ -396,7 +396,7 @@ mod tests { // Add key 0x22 by rewriting the header with a second slot. let mut new_hdr = db.crypto_header.unwrap(); let (_, dek) = new_hdr.unlock(&raw(0x11)).unwrap(); - new_hdr.wrap_into(1, &raw(0x22), &dek); + new_hdr.wrap_into(1, &raw(0x22), &dek).expect("wrap_into with valid key must succeed"); db.rewrite_crypto_header(new_hdr).unwrap(); // Drop to flush OS buffers (fsync already called). drop(db); From 73f5b3d3de46b11d3e3bacbaca04329a429052d5 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 20:40:48 -0700 Subject: [PATCH 38/42] docs: fix encryption comments and ARCHITECTURE prefix layout - 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). --- ARCHITECTURE.md | 2 +- src/crypto/mod.rs | 3 ++- src/error.rs | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f82f7dd..1e45821 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -624,7 +624,7 @@ Chisel supports optional authenticated encryption of database files. An encrypte **On-disk stride.** Encrypted databases use a uniform 8232-byte stride for every page including the superblock slots. Plaintext databases continue to use the 8192-byte stride; the two are mutually exclusive and the stride is recorded in the superblock's plaintext crypto-header so the engine reads the correct number of bytes before attempting any operation. The `page_io` layer is stride-agnostic: callers set the stride once (via `PageIo::set_stride`) and all subsequent raw reads and writes use it. -**Envelope (key hierarchy).** A random 256-bit per-database encryption key (DEK) encrypts all page content. The DEK itself is never stored in plaintext: it is wrapped under a key-encryption key (KEK) and the wrapped form is held in a plaintext key-slot table inside the superblock's reserved region (bytes 324..1356; 8 slots × 128 bytes each, preceded by a 1-byte algorithm id and 4-byte stride field at byte 324). Each slot stores the KDF identity, KDF parameters, salt, wrap nonce, wrapped DEK, and wrap tag. There are two KEK derivation paths: +**Envelope (key hierarchy).** A random 256-bit per-database encryption key (DEK) encrypts all page content. The DEK itself is never stored in plaintext: it is wrapped under a key-encryption key (KEK) and the wrapped form is held in a plaintext key-slot table inside the superblock's reserved region (bytes 324..1356; 8 slots × 128 bytes each, preceded by an 8-byte prefix (1-byte algorithm id, 4-byte stride, 3 reserved bytes); the key-slot table begins at byte 332). Each slot stores the KDF identity, KDF parameters, salt, wrap nonce, wrapped DEK, and wrap tag. There are two KEK derivation paths: - **Raw key** (`Key::Raw`): KEK = HKDF-SHA256(ikm=key material, salt=slot salt, info=`"chisel-kek"`). - **Passphrase** (`Key::Passphrase`): KEK = Argon2id(password, salt, m/t/p from the slot's stored parameters). diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 5cc884e..2d3cbc0 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -284,7 +284,8 @@ pub fn random_dek() -> Dek { /// Holds the DEK and performs the two seal/open transforms the engine needs: /// whole-page (fixed 8192→8232) and variable-length body (superblock sub-blob). /// Lives in the page-cache layer in later phases; here it is fully standalone. -/// Constructs the AEAD cipher once and reuses it across calls. +/// The AEAD cipher is derived from the DEK on each `seal`/`open` call; this is +/// cheap for XChaCha20-Poly1305 and avoids holding any additional per-call state. /// /// `Clone` produces an independent copy with its own `Zeroizing` DEK (both /// copies wipe on drop independently). Used when the cache and the session diff --git a/src/error.rs b/src/error.rs index 217f907..2151417 100644 --- a/src/error.rs +++ b/src/error.rs @@ -657,7 +657,7 @@ mod tests { "Display {msg:?} should mention page id 7" ); - // source() is None for all four — none wrap an inner cause. + // source() is None for all six — none wrap an inner cause. use std::error::Error; for e in [ ChiselError::NoEncryptionKey, From e0c72d3c89135881a46246f152edabeb1e8da610 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 23:58:12 -0700 Subject: [PATCH 39/42] refactor(test): relocate internal-crypto tests inline after pub(crate) scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/crypto/mod.rs | 8 ++ .../transaction/create_tests.rs | 82 ++++++++----------- src/transaction/mod.rs | 2 + 3 files changed, 44 insertions(+), 48 deletions(-) rename tests/encryption_create.rs => src/transaction/create_tests.rs (80%) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 2d3cbc0..014d592 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -80,6 +80,7 @@ impl Clone for Dek { pub struct Kek(Zeroizing<[u8; 32]>); impl Kek { + #[cfg(test)] pub fn from_bytes(bytes: [u8; 32]) -> Self { Kek(Zeroizing::new(bytes)) } @@ -162,6 +163,13 @@ pub fn derive_kek( Key::Raw(bytes) => bytes.as_slice(), Key::Passphrase(s) => s.as_bytes(), }; + // Reject empty key material: HKDF and Argon2id both accept a zero-length + // ikm and would silently derive a KEK from nothing, so a caller-supplied + // empty key must be refused at this trust boundary rather than producing a + // usable wrap. Documented in this function's # Errors as BadKeyLength. + if ikm.is_empty() { + return Err(CryptoError::BadKeyLength); + } // Zeroizing so the derived key never lingers un-wiped on the stack: on the // success path it is MOVED into Kek (no copy left behind), and on any error // path partial KDF output is wiped on drop. diff --git a/tests/encryption_create.rs b/src/transaction/create_tests.rs similarity index 80% rename from tests/encryption_create.rs rename to src/transaction/create_tests.rs index 8b5e86c..ab4378c 100644 --- a/tests/encryption_create.rs +++ b/src/transaction/create_tests.rs @@ -1,21 +1,25 @@ -// encryption_create.rs — Integration tests for Task 2.3: create_new with a key. -// -// Scope: verify the CREATE ARTIFACT (the serialised page 0) without exercising -// the open path (the MAJOR=2 gate is Task 2.4). Concretely: -// - MAJOR is 2 in the on-disk superblock -// - slot 0 is populated (state=active) -// - sensitive fields (named_roots names) are NOT in cleartext -// - the wrapped DEK round-trips: unwrap_dek under the same key recovers -// a valid DEK (smoke-tests the wrap/AAD path without going through open) -// -// All file-backed tests use a tempfile that is deleted on drop. - -use chisel::{ - derive_kek, unwrap_dek, CryptoHeader, KdfId, Key, KeySlot, - Options, ALGO_XCHACHA20POLY1305, CRYPTO_HEADER_OFFSET, KEY_SLOT_COUNT, KEY_SLOT_SIZE, - PAGE_SIZE, +//! transaction::create_tests — create-artifact tests for encrypted DBs. +//! +//! Scope: verify the CREATE ARTIFACT (the serialised page 0) without exercising +//! the open path. Concretely: +//! - MAJOR is 2 in the on-disk superblock +//! - slot 0 is populated (state=active) +//! - sensitive fields (named_roots names) are NOT in cleartext +//! - the wrapped DEK round-trips: unwrap_dek under the same key recovers +//! a valid DEK (smoke-tests the wrap/AAD path without going through open) +//! +//! Relocated from tests/encryption_create.rs after the crypto/superblock/format +//! internals were scoped to pub(crate); these assertions need direct access to +//! CryptoHeader/KeySlot/derive_kek/unwrap_dek which are no longer public. All +//! file-backed tests use a tempfile that is deleted on drop. + +use crate::crypto::{derive_kek, unwrap_dek, KdfId, Key}; +use crate::page::{format_major, FORMAT_MAJOR_VERSION_ENCRYPTED, PAGE_SIZE}; +use crate::superblock::{ + CryptoHeader, KeySlot, ALGO_XCHACHA20POLY1305, CRYPTO_HEADER_OFFSET, KEY_SLOT_COUNT, + KEY_SLOT_SIZE, }; -use chisel::{format_major, FORMAT_MAJOR_VERSION_ENCRYPTED}; +use crate::Options; use std::fs; use std::io::Read as _; use zeroize::Zeroizing; @@ -60,11 +64,8 @@ impl Drop for TempDb { fn create_encrypted_db_stamps_major_2() { let tmp = TempDb::new("major2_raw"); let key = Key::Raw(Zeroizing::new(vec![0xAB_u8; 32])); - let db = chisel::Chisel::open( - tmp.path(), - Options::default().encryption_key(key), - ) - .expect("create encrypted db"); + let db = crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)) + .expect("create encrypted db"); drop(db); let page0 = tmp.read_page0(); @@ -81,11 +82,8 @@ fn create_encrypted_db_stamps_major_2() { fn create_encrypted_db_passphrase_stamps_major_2() { let tmp = TempDb::new("major2_pass"); let key = Key::Passphrase(Zeroizing::new("hunter2".to_string())); - let db = chisel::Chisel::open( - tmp.path(), - Options::default().encryption_key(key), - ) - .expect("create encrypted db passphrase"); + let db = crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)) + .expect("create encrypted db passphrase"); drop(db); let page0 = tmp.read_page0(); @@ -99,11 +97,8 @@ fn create_encrypted_db_passphrase_stamps_major_2() { fn create_encrypted_db_populates_slot_0_only() { let tmp = TempDb::new("slot0"); let key = Key::Raw(Zeroizing::new(vec![0x77_u8; 32])); - let db = chisel::Chisel::open( - tmp.path(), - Options::default().encryption_key(key), - ) - .expect("create"); + let db = crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)) + .expect("create"); drop(db); let page0 = tmp.read_page0(); @@ -148,11 +143,8 @@ fn create_encrypted_db_sealed_body_is_present() { let tmp = TempDb::new("cleartext_check"); let key = Key::Raw(Zeroizing::new(vec![0xCC_u8; 32])); - let db = chisel::Chisel::open( - tmp.path(), - Options::default().encryption_key(key), - ) - .expect("create"); + let db = crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)) + .expect("create"); drop(db); let page0 = tmp.read_page0(); @@ -172,16 +164,13 @@ fn create_encrypted_db_sealed_body_is_present() { /// from the same raw key, and verify `unwrap_dek` succeeds. /// /// This exercises the wrap→unwrap round-trip without going through the open -/// path (which Task 2.4 implements). +/// path. #[test] fn slot0_dek_unwraps_with_correct_key() { let tmp = TempDb::new("unwrap"); let key = Key::Raw(Zeroizing::new(vec![0x5A_u8; 32])); - let db = chisel::Chisel::open( - tmp.path(), - Options::default().encryption_key(key.clone()), - ) - .expect("create"); + let db = crate::Chisel::open(tmp.path(), Options::default().encryption_key(key.clone())) + .expect("create"); drop(db); let page0 = tmp.read_page0(); @@ -223,11 +212,8 @@ fn slot0_dek_unwraps_with_correct_key() { fn slot0_dek_unwrap_fails_with_wrong_key() { let tmp = TempDb::new("wrong_key"); let key = Key::Raw(Zeroizing::new(vec![0x5A_u8; 32])); - let db = chisel::Chisel::open( - tmp.path(), - Options::default().encryption_key(key), - ) - .expect("create"); + let db = crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)) + .expect("create"); drop(db); let page0 = tmp.read_page0(); diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index 927b6ee..5f0e875 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -239,6 +239,8 @@ pub struct TransactionManager { mod commit; mod config; #[cfg(test)] +mod create_tests; +#[cfg(test)] mod fault; mod freemap; mod keys; From 98c181592bacc5875abfb5febd82e09ce5de8c77 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Tue, 30 Jun 2026 23:58:28 -0700 Subject: [PATCH 40/42] test(crypto): Argon2id KAT, in-memory encryption, rewrite_crypto_header 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. --- src/crypto/mod.rs | 31 ++++++++++++++++++ src/transaction/keys.rs | 61 ++++++++++++++++++++++++++++++++++- tests/encryption_roundtrip.rs | 44 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 014d592..6e8ecbf 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -681,4 +681,35 @@ mod tests { let _r2 = raw.clone(); let _p2 = pass.clone(); } + + #[test] + fn argon2id_known_answer_test() { + // Golden regression pin for our Argon2id KDF path. Input: password=b"password", + // salt=b"somesalt12345678" (16 bytes), m_cost=8, t_cost=1, p_cost=1, tag=32, + // algorithm=Argon2id, version=0x13. These are deliberately cheap params so the + // test is instant; they differ from production defaults (OWASP: m=19456). + // + // This is a golden (regression) pin computed from the argon2 crate, not an + // independently-published first-principles vector — the RFC 9106 reference + // vectors use secret+AD parameters our derive_kek API does not expose. + // The pin's value: if this test fails, the KDF configuration silently changed + // (algorithm, version, output length, info string) and existing key slots would + // fail to unwrap. That is a data-loss event; the test must be updated + // deliberately and the format version bumped. + let key = Key::Passphrase(zeroize::Zeroizing::new("password".to_string())); + let salt = *b"somesalt12345678"; + let params = Argon2Params { m_cost: 8, t_cost: 1, p_cost: 1 }; + let kek = derive_kek(&key, KdfId::Argon2id, &salt, ¶ms).unwrap(); + let expected: [u8; 32] = [ + 0xd8, 0x38, 0x04, 0x14, 0x00, 0x12, 0xc3, 0xe6, + 0xd3, 0x50, 0x2a, 0x3e, 0xb5, 0x9f, 0xc2, 0x4a, + 0x89, 0xa9, 0xec, 0x08, 0xb6, 0xac, 0x97, 0xbe, + 0x1f, 0xec, 0xa1, 0x70, 0x0a, 0xbe, 0x0a, 0xfb, + ]; + assert_eq!( + kek.as_bytes(), + &expected, + "Argon2id output changed — KDF config or format break; update golden and bump FORMAT_VERSION" + ); + } } diff --git a/src/transaction/keys.rs b/src/transaction/keys.rs index e3fa3ab..69ad333 100644 --- a/src/transaction/keys.rs +++ b/src/transaction/keys.rs @@ -201,7 +201,7 @@ impl TransactionManager { mod tests { use super::*; use crate::crypto::Key; - use crate::page_io::PageIo; + use crate::page_io::{Fault, PageIo}; use tempfile::NamedTempFile; use zeroize::Zeroizing; @@ -415,4 +415,63 @@ mod tests { let stored = db2.crypto_header.unwrap(); assert_eq!(stored.active_count(), 2, "both slots must survive reopen"); } + + /// An fsync failure inside rewrite_crypto_header (called by add_key / rotate_key) + /// must poison the manager — the I1 poison-on-fatal invariant covers the key- + /// rotation path, not just data commits. + /// + /// rewrite_crypto_header_inner writes one superblock and then fsyncs; that fsync + /// is the only fsync in the call (no data pages are touched). Arming + /// `Fault::FailFsync(0)` catches it on the first call. + #[test] + fn rewrite_crypto_header_fsync_failure_poisons() { + let file = NamedTempFile::new().unwrap(); + let io = PageIo::open(file.path(), false).unwrap(); + let cache = PageCache::new( + io, + 1024 * PAGE_SIZE as u64, + 0, + crate::DrainInsertion::LruTail, + crate::SpillwayLocation::InMemory, + ); + let mut db = TransactionManager::create_new(cache, 2, Some(raw(0x11)), None).unwrap(); + db.begin().unwrap(); + db.commit().unwrap(); + + // Arm a fault on the first fsync so rewrite_crypto_header's superblock + // write succeeds but its fsync fails — this is the I1 trigger point. + db.cache.borrow().io().arm_fault(Fault::FailFsync(0)); + + // add_key calls rewrite_crypto_header; the fsync fault must surface as + // IoError and leave the manager poisoned. + let result = db.add_key(&raw(0x11), &raw(0x22)); + assert!( + matches!(result, Err(ChiselError::IoError(_))), + "fsync failure in rewrite_crypto_header must surface IoError, got {result:?}" + ); + assert!( + db.is_poisoned(), + "fsync failure in rewrite_crypto_header must poison the manager" + ); + // Subsequent operations must be rejected with Poisoned. + assert!( + matches!(db.begin(), Err(ChiselError::Poisoned)), + "poisoned manager must reject further ops" + ); + } + + /// add_key with a zero-length raw key must return an error, not panic. + /// derive_kek rejects empty key material with BadKeyLength; wrap_into + /// propagates it and add_key maps it to InvalidEncryptionKey. + #[test] + fn add_key_with_invalid_raw_key_returns_error_not_panic() { + let mut db = fresh_encrypted(); + let bad_new = Key::Raw(Zeroizing::new(vec![])); // zero-length key material + // This must NOT panic; it must return an Err. + let result = db.add_key(&raw(0x11), &bad_new); + assert!( + matches!(result, Err(ChiselError::InvalidEncryptionKey)), + "empty raw key must return InvalidEncryptionKey, not panic; got {result:?}" + ); + } } diff --git a/tests/encryption_roundtrip.rs b/tests/encryption_roundtrip.rs index 2cd8af5..31add60 100644 --- a/tests/encryption_roundtrip.rs +++ b/tests/encryption_roundtrip.rs @@ -3,6 +3,10 @@ // Documents the three-case guarantee for encrypted databases: create + write // with a key → reopen with the SAME key reads the value back; reopen with a // WRONG key → InvalidEncryptionKey; reopen with NO key → NoEncryptionKey. +// Also covers the in-memory encrypted path: a DB opened with +// open_in_memory_with_options + an encryption key must write and read within +// the same session (there is no reopen for in-memory DBs, so wrong-key is +// not applicable). // // Uses a raw 32-byte key to avoid paying the Argon2id cost. Passphrase // derivation is exercised in the crypto unit tests. Uses only the public API @@ -73,3 +77,43 @@ fn encrypted_roundtrip_and_wrong_key() { ); } } + +#[test] +fn in_memory_encrypted_roundtrip() { + // An in-memory encrypted DB must write and read back within the same session. + // There is no reopen for in-memory DBs, so the test covers the allocate → + // commit → read path under encryption without touching disk. + let mut db = Chisel::open_in_memory_with_options( + Options::default().encryption_key(raw_key(0x7F)), + ) + .expect("open in-memory encrypted"); + + db.begin().expect("begin"); + let h1 = db.allocate(b"in-memory-value-alpha").expect("allocate h1"); + let h2 = db.allocate(b"in-memory-value-beta").expect("allocate h2"); + db.commit().expect("commit"); + + assert_eq!(db.read(h1).expect("read h1"), b"in-memory-value-alpha"); + assert_eq!(db.read(h2).expect("read h2"), b"in-memory-value-beta"); + + // A second transaction: update h1, verify h2 is unchanged. + db.begin().expect("begin 2"); + db.update(h1, b"updated-alpha").expect("update h1"); + db.commit().expect("commit 2"); + + assert_eq!(db.read(h1).expect("read h1 after update"), b"updated-alpha"); + assert_eq!(db.read(h2).expect("read h2 unchanged"), b"in-memory-value-beta"); +} + +#[test] +fn in_memory_plaintext_roundtrip_baseline() { + // Sanity baseline: plain in-memory DB (no key) works the same way. + // Ensures any regression in the in-memory path is distinguishable from + // an encryption-specific failure. + let mut db = + Chisel::open_in_memory_with_options(Options::default()).expect("open in-memory plaintext"); + db.begin().expect("begin"); + let h = db.allocate(b"plain").expect("allocate"); + db.commit().expect("commit"); + assert_eq!(db.read(h).expect("read"), b"plain"); +} From 6c936368eaa9cf4b0a933b7dfb674d57bc476c7e Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Wed, 1 Jul 2026 08:48:18 -0700 Subject: [PATCH 41/42] style: rustfmt the encryption-branch files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- python/src/errors.rs | 5 ++- src/crypto/mod.rs | 13 +++--- src/page.rs | 6 ++- src/page_cache.rs | 14 ++++--- src/page_io.rs | 6 +-- src/spillway.rs | 8 +++- src/superblock/crypto_header.rs | 49 +++++++++++++++++------ src/superblock/mod.rs | 29 ++++++++++---- src/transaction/create_tests.rs | 55 ++++++++++++++++++-------- src/transaction/keys.rs | 70 ++++++++++++++++++++++++--------- src/transaction/recovery.rs | 33 ++++++++++------ tests/encryption_keys.rs | 2 +- tests/encryption_open.rs | 60 ++++++++-------------------- tests/encryption_roundtrip.rs | 19 ++++----- tests/public_key_api.rs | 4 +- 15 files changed, 233 insertions(+), 140 deletions(-) diff --git a/python/src/errors.rs b/python/src/errors.rs index d515f6c..3684faa 100644 --- a/python/src/errors.rs +++ b/python/src/errors.rs @@ -226,7 +226,10 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { py.get_type::(), )?; m.add("TagMismatchError", py.get_type::())?; - m.add("NoEncryptionKeyError", py.get_type::())?; + m.add( + "NoEncryptionKeyError", + py.get_type::(), + )?; m.add( "InvalidEncryptionKeyError", py.get_type::(), diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 6e8ecbf..afa6496 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -698,13 +698,16 @@ mod tests { // deliberately and the format version bumped. let key = Key::Passphrase(zeroize::Zeroizing::new("password".to_string())); let salt = *b"somesalt12345678"; - let params = Argon2Params { m_cost: 8, t_cost: 1, p_cost: 1 }; + let params = Argon2Params { + m_cost: 8, + t_cost: 1, + p_cost: 1, + }; let kek = derive_kek(&key, KdfId::Argon2id, &salt, ¶ms).unwrap(); let expected: [u8; 32] = [ - 0xd8, 0x38, 0x04, 0x14, 0x00, 0x12, 0xc3, 0xe6, - 0xd3, 0x50, 0x2a, 0x3e, 0xb5, 0x9f, 0xc2, 0x4a, - 0x89, 0xa9, 0xec, 0x08, 0xb6, 0xac, 0x97, 0xbe, - 0x1f, 0xec, 0xa1, 0x70, 0x0a, 0xbe, 0x0a, 0xfb, + 0xd8, 0x38, 0x04, 0x14, 0x00, 0x12, 0xc3, 0xe6, 0xd3, 0x50, 0x2a, 0x3e, 0xb5, 0x9f, + 0xc2, 0x4a, 0x89, 0xa9, 0xec, 0x08, 0xb6, 0xac, 0x97, 0xbe, 0x1f, 0xec, 0xa1, 0x70, + 0x0a, 0xbe, 0x0a, 0xfb, ]; assert_eq!( kek.as_bytes(), diff --git a/src/page.rs b/src/page.rs index 5bdc816..5ea7785 100644 --- a/src/page.rs +++ b/src/page.rs @@ -128,8 +128,10 @@ pub(crate) const FORMAT_MINOR_VERSION_ENCRYPTED: u16 = 0; /// through that function, so there is exactly ONE on-disk value — no two /// constants that can drift. An encryption-unaware binary (FORMAT_MAJOR_VERSION /// == 1) rejects it as `UnsupportedFormatVersion`, the intended hard-reject. -pub(crate) const ENCRYPTED_FORMAT_VERSION: u32 = - pack_format_version(FORMAT_MAJOR_VERSION_ENCRYPTED, FORMAT_MINOR_VERSION_ENCRYPTED); +pub(crate) const ENCRYPTED_FORMAT_VERSION: u32 = pack_format_version( + FORMAT_MAJOR_VERSION_ENCRYPTED, + FORMAT_MINOR_VERSION_ENCRYPTED, +); /// The encrypted-DB packed format version (MAJOR=2, MINOR=0). Single source of /// truth: returns `ENCRYPTED_FORMAT_VERSION`. Called by the create path diff --git a/src/page_cache.rs b/src/page_cache.rs index d018b2a..e81a2dd 100644 --- a/src/page_cache.rs +++ b/src/page_cache.rs @@ -1009,7 +1009,8 @@ impl PageCache { // the plaintext branch runs verify_checksum on the raw page bytes as before. let mut on_disk = [0u8; ENC_PAGE_SIZE]; let stride = self.io.stride(); - self.io.read_page_unit_into(page_id, &mut on_disk[..stride])?; + self.io + .read_page_unit_into(page_id, &mut on_disk[..stride])?; let plaintext: [u8; PAGE_SIZE] = match &self.cipher { Some(c) => { @@ -1900,7 +1901,7 @@ mod tests { // Encrypted page-cache tests (Task 3.3) // ----------------------------------------------------------------------- - use crate::crypto::{random_dek, ENC_PAGE_SIZE, PageCipher}; + use crate::crypto::{random_dek, PageCipher, ENC_PAGE_SIZE}; /// Build a file-backed cache with stride=ENC_PAGE_SIZE and a PageCipher /// installed. The stride must be set on the PageIo BEFORE construction so @@ -2033,9 +2034,7 @@ mod tests { // encrypted DB that blob is XChaCha20-Poly1305 ciphertext — the sentinel // must NOT appear verbatim anywhere in it. let blob = cache.spillway.as_mut().unwrap().rehydrate(id_a).unwrap(); - let sentinel_pos = blob - .windows(8) - .position(|w| w == b"SENTINEL"); + let sentinel_pos = blob.windows(8).position(|w| w == b"SENTINEL"); assert!( sentinel_pos.is_none(), "plaintext sentinel found verbatim in spillway slot — spill did not encrypt" @@ -2138,7 +2137,10 @@ mod tests { for (n, &pid) in ids.iter().enumerate() { let buf = cache.get(pid).unwrap(); - assert_eq!(buf[0], n as u8, "page {pid} byte[0] mismatch after full round trip"); + assert_eq!( + buf[0], n as u8, + "page {pid} byte[0] mismatch after full round trip" + ); assert_eq!( buf[100], (n as u8).wrapping_mul(7), diff --git a/src/page_io.rs b/src/page_io.rs index 8dc6370..33b58a1 100644 --- a/src/page_io.rs +++ b/src/page_io.rs @@ -414,8 +414,7 @@ impl PageIo { /// never accidentally alias the underlying File. pub fn read_page(&mut self, page_id: u64) -> Result<[u8; PAGE_SIZE]> { debug_assert_eq!( - self.stride, - PAGE_SIZE, + self.stride, PAGE_SIZE, "read_page called on an encrypted stride; use read_page_unit" ); let blob = self.read_page_unit(page_id)?; @@ -430,8 +429,7 @@ impl PageIo { /// `write_page_unit` for the encrypted path. Not durable until `fsync()`. pub fn write_page(&mut self, page_id: u64, buf: &[u8; PAGE_SIZE]) -> Result<()> { debug_assert_eq!( - self.stride, - PAGE_SIZE, + self.stride, PAGE_SIZE, "write_page called on an encrypted stride; use write_page_unit" ); self.write_page_unit(page_id, buf) diff --git a/src/spillway.rs b/src/spillway.rs index 1519dd0..a37013c 100644 --- a/src/spillway.rs +++ b/src/spillway.rs @@ -239,7 +239,13 @@ impl Spillway { new_index }; - write_slot(&mut self.backing, slot_index, page_id, blob, self.payload_size)?; + write_slot( + &mut self.backing, + slot_index, + page_id, + blob, + self.payload_size, + )?; Ok(()) } diff --git a/src/superblock/crypto_header.rs b/src/superblock/crypto_header.rs index 9eaef46..119abce 100644 --- a/src/superblock/crypto_header.rs +++ b/src/superblock/crypto_header.rs @@ -59,7 +59,11 @@ impl KeySlot { pub const EMPTY: KeySlot = KeySlot { state: 0, kdf_id: 0, - argon2: Argon2Params { m_cost: 0, t_cost: 0, p_cost: 0 }, + argon2: Argon2Params { + m_cost: 0, + t_cost: 0, + p_cost: 0, + }, salt: [0u8; SALT_LEN], wrap_nonce: [0u8; NONCE_LEN], wrapped_dek: [0u8; DEK_LEN], @@ -153,7 +157,11 @@ impl CryptoHeader { let base = SLOT_TABLE_OFFSET + i * KEY_SLOT_SIZE; *slot = KeySlot::read_from(&buf[base..base + KEY_SLOT_SIZE]); } - Some(CryptoHeader { algorithm, stride, slots }) + Some(CryptoHeader { + algorithm, + stride, + slots, + }) } /// Count how many slots currently hold a wrapped DEK (state == active). @@ -233,9 +241,14 @@ impl CryptoHeader { ) -> Result<(), crate::crypto::CryptoError> { use crate::crypto::{self, KdfId}; let (kdf_id, argon2) = match key { - crate::crypto::Key::Raw(_) => { - (KdfId::Hkdf, Argon2Params { m_cost: 0, t_cost: 0, p_cost: 0 }) - } + crate::crypto::Key::Raw(_) => ( + KdfId::Hkdf, + Argon2Params { + m_cost: 0, + t_cost: 0, + p_cost: 0, + }, + ), crate::crypto::Key::Passphrase(_) => (KdfId::Argon2id, Argon2Params::default()), }; let salt: [u8; SALT_LEN] = crypto::random_array(); @@ -280,7 +293,8 @@ mod crypto_header_tests { stride: crypto::ENC_PAGE_SIZE as u32, slots: [KeySlot::EMPTY; KEY_SLOT_COUNT], }; - h.wrap_into(0, key, dek).expect("wrap_into with valid key must succeed"); + h.wrap_into(0, key, dek) + .expect("wrap_into with valid key must succeed"); h } @@ -292,7 +306,8 @@ mod crypto_header_tests { // Add a second credential into slot 3 wrapping the SAME dek. let k1 = raw(0xB2); - h.wrap_into(3, &k1, &dek).expect("wrap_into with valid key must succeed"); + h.wrap_into(3, &k1, &dek) + .expect("wrap_into with valid key must succeed"); let (idx0, d0) = h.unlock(&k0).expect("k0 must unlock"); let (idx1, d1) = h.unlock(&k1).expect("k1 must unlock"); @@ -309,7 +324,10 @@ mod crypto_header_tests { let h = header_with_one(&raw(0xAA), &dek); // Dek has no Debug, so we can't use expect_err(); use matches! instead. let result = h.unlock(&raw(0xBB)); - assert!(matches!(result, Err(crate::error::ChiselError::InvalidEncryptionKey))); + assert!(matches!( + result, + Err(crate::error::ChiselError::InvalidEncryptionKey) + )); } #[test] @@ -349,7 +367,8 @@ mod crypto_header_tests { stride: crypto::ENC_PAGE_SIZE as u32, slots: [KeySlot::EMPTY; KEY_SLOT_COUNT], }; - h.wrap_into(5, &key, &dek).expect("wrap_into with valid key must succeed"); + h.wrap_into(5, &key, &dek) + .expect("wrap_into with valid key must succeed"); let (idx, recovered) = h.unlock(&key).expect("wrap_into then unlock must succeed"); assert_eq!(idx, 5); assert_eq!(recovered.as_bytes(), dek.as_bytes()); @@ -366,7 +385,11 @@ mod tests { KeySlot { state, kdf_id: 1, - argon2: Argon2Params { m_cost: 19456, t_cost: 2, p_cost: 1 }, + argon2: Argon2Params { + m_cost: 19456, + t_cost: 2, + p_cost: 1, + }, salt: [7u8; 16], wrap_nonce: [9u8; 24], wrapped_dek: [3u8; 32], @@ -379,7 +402,11 @@ mod tests { let mut slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; slots[0] = sample_slot(1); // active slots[3] = sample_slot(1); // active - let header = CryptoHeader { algorithm: 1, stride: 8232, slots }; + let header = CryptoHeader { + algorithm: 1, + stride: 8232, + slots, + }; let mut buf = [0u8; PAGE_SIZE]; header.serialize_into(&mut buf); diff --git a/src/superblock/mod.rs b/src/superblock/mod.rs index 6970949..6c99085 100644 --- a/src/superblock/mod.rs +++ b/src/superblock/mod.rs @@ -278,9 +278,8 @@ const BODY_LEN: usize = 8 * 5 + 4 + (NAMED_ROOT_COUNT * NAMED_ROOT_ENTRY_SIZE); // Compile-time check: the sealed blob fits before the checksum. // SEALED_BODY_OFFSET(1356) + NONCE_LEN(24) + TAG_LEN(16) + 2(len) + BODY_LEN. -const _: () = assert!( - SEALED_BODY_OFFSET + NONCE_LEN + TAG_LEN + 2 + BODY_LEN <= page::CHECKSUM_OFFSET -); +const _: () = + assert!(SEALED_BODY_OFFSET + NONCE_LEN + TAG_LEN + 2 + BODY_LEN <= page::CHECKSUM_OFFSET); impl Superblock { /// Serialize the superblock into a full page buffer with a trailing checksum. @@ -367,7 +366,9 @@ impl Superblock { self.freemap_depth = u32::from_le_bytes(body[40..44].try_into().unwrap()); let mut off = 44; for entry in self.named_roots.iter_mut() { - entry.name.copy_from_slice(&body[off..off + NAMED_ROOT_NAME_LEN]); + entry + .name + .copy_from_slice(&body[off..off + NAMED_ROOT_NAME_LEN]); entry.handle = u64::from_le_bytes( body[off + NAMED_ROOT_NAME_LEN..off + NAMED_ROOT_NAME_LEN + 8] .try_into() @@ -967,13 +968,21 @@ mod tests { // Sensitive bytes must be absent from cleartext. // named_roots occupy 52..308; all must be zero in the encrypted page. - assert_eq!(&buf[52..308], &[0u8; 256][..], "named_roots leaked in cleartext"); + assert_eq!( + &buf[52..308], + &[0u8; 256][..], + "named_roots leaked in cleartext" + ); // Scalar sensitive fields at 16..48 must be zero. assert_eq!(&buf[16..48], &[0u8; 32][..], "sensitive scalars leaked"); // root_membership_index_page (312..320) and freemap_depth (320..324) // are also sealed-only, so their plaintext slots must be zero. Bytes // 308..312 (superblock_count) are legitimately cleartext and skipped. - assert_eq!(&buf[312..324], &[0u8; 12][..], "membership/freemap_depth leaked"); + assert_eq!( + &buf[312..324], + &[0u8; 12][..], + "membership/freemap_depth leaked" + ); // Bootstrap fields stay plaintext. assert_eq!(u32::from_le_bytes(buf[0..4].try_into().unwrap()), MAGIC); assert_eq!( @@ -983,7 +992,10 @@ mod tests { // Two-phase deserialize: sensitive fields are zero after deserialize. let mut back = Superblock::deserialize(&buf).expect("encrypted sb deserializes"); - assert!(back.encryption.is_some(), "encryption field must be populated"); + assert!( + back.encryption.is_some(), + "encryption field must be populated" + ); assert_eq!(back.root_handle_table_page, 0, "not yet decrypted"); assert_eq!(back.next_handle, 0, "not yet decrypted"); @@ -1102,7 +1114,8 @@ mod tests { // 3. Round-trip: decrypt_body must recover the sentinel, proving it was // sealed (not silently dropped). let mut back = Superblock::deserialize(&buf).expect("encrypted sb must deserialize"); - back.decrypt_body(&cipher, &buf).expect("correct DEK must open body"); + back.decrypt_body(&cipher, &buf) + .expect("correct DEK must open body"); assert_eq!( back.named_roots[0].name, sentinel, "named_root name not recovered after decrypt_body" diff --git a/src/transaction/create_tests.rs b/src/transaction/create_tests.rs index ab4378c..6dae803 100644 --- a/src/transaction/create_tests.rs +++ b/src/transaction/create_tests.rs @@ -30,8 +30,11 @@ struct TempDb(std::path::PathBuf); impl TempDb { fn new(stem: &str) -> Self { - let p = std::env::temp_dir() - .join(format!("chisel_enc_test_{}_{}.db", stem, std::process::id())); + let p = std::env::temp_dir().join(format!( + "chisel_enc_test_{}_{}.db", + stem, + std::process::id() + )); let _ = fs::remove_file(&p); TempDb(p) } @@ -97,16 +100,15 @@ fn create_encrypted_db_passphrase_stamps_major_2() { fn create_encrypted_db_populates_slot_0_only() { let tmp = TempDb::new("slot0"); let key = Key::Raw(Zeroizing::new(vec![0x77_u8; 32])); - let db = crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)) - .expect("create"); + let db = + crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)).expect("create"); drop(db); let page0 = tmp.read_page0(); // Algorithm byte is the first byte of the crypto-header region. assert_eq!( - page0[CRYPTO_HEADER_OFFSET], - ALGO_XCHACHA20POLY1305, + page0[CRYPTO_HEADER_OFFSET], ALGO_XCHACHA20POLY1305, "algorithm byte must be 1 (XChaCha20-Poly1305)" ); @@ -115,7 +117,10 @@ fn create_encrypted_db_populates_slot_0_only() { let slot_table_offset = CRYPTO_HEADER_OFFSET + 8; // Slot 0 state byte must be 1 (active). - assert_eq!(page0[slot_table_offset], 1, "slot 0 state must be active (1)"); + assert_eq!( + page0[slot_table_offset], 1, + "slot 0 state must be active (1)" + ); // Slots 1..KEY_SLOT_COUNT must all be empty (state = 0). for i in 1..KEY_SLOT_COUNT { @@ -143,8 +148,8 @@ fn create_encrypted_db_sealed_body_is_present() { let tmp = TempDb::new("cleartext_check"); let key = Key::Raw(Zeroizing::new(vec![0xCC_u8; 32])); - let db = crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)) - .expect("create"); + let db = + crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)).expect("create"); drop(db); let page0 = tmp.read_page0(); @@ -200,11 +205,21 @@ fn slot0_dek_unwraps_with_correct_key() { let aad = aad_slot.aad(); // Unwrap must succeed. - let dek = unwrap_dek(&kek, &slot.wrapped_dek, &slot.wrap_tag, &slot.wrap_nonce, &aad) - .expect("unwrap_dek must succeed with the correct key and AAD"); + let dek = unwrap_dek( + &kek, + &slot.wrapped_dek, + &slot.wrap_tag, + &slot.wrap_nonce, + &aad, + ) + .expect("unwrap_dek must succeed with the correct key and AAD"); // The DEK must be non-trivial (not all zeros). - assert_ne!(dek.as_bytes(), &[0u8; 32], "unwrapped DEK must not be all zeros"); + assert_ne!( + dek.as_bytes(), + &[0u8; 32], + "unwrapped DEK must not be all zeros" + ); } /// Wrong key must fail unwrap (AEAD authentication failure). @@ -212,8 +227,8 @@ fn slot0_dek_unwraps_with_correct_key() { fn slot0_dek_unwrap_fails_with_wrong_key() { let tmp = TempDb::new("wrong_key"); let key = Key::Raw(Zeroizing::new(vec![0x5A_u8; 32])); - let db = crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)) - .expect("create"); + let db = + crate::Chisel::open(tmp.path(), Options::default().encryption_key(key)).expect("create"); drop(db); let page0 = tmp.read_page0(); @@ -221,8 +236,7 @@ fn slot0_dek_unwrap_fails_with_wrong_key() { let slot = &header.slots[0]; let wrong_key = Key::Raw(Zeroizing::new(vec![0xFF_u8; 32])); - let kek = - derive_kek(&wrong_key, KdfId::Hkdf, &slot.salt, &slot.argon2).expect("derive_kek"); + let kek = derive_kek(&wrong_key, KdfId::Hkdf, &slot.salt, &slot.argon2).expect("derive_kek"); let mut aad_slot = KeySlot::EMPTY; aad_slot.state = slot.state; @@ -233,7 +247,14 @@ fn slot0_dek_unwrap_fails_with_wrong_key() { let aad = aad_slot.aad(); assert!( - unwrap_dek(&kek, &slot.wrapped_dek, &slot.wrap_tag, &slot.wrap_nonce, &aad).is_err(), + unwrap_dek( + &kek, + &slot.wrapped_dek, + &slot.wrap_tag, + &slot.wrap_nonce, + &aad + ) + .is_err(), "wrong key must fail DEK unwrap" ); } diff --git a/src/transaction/keys.rs b/src/transaction/keys.rs index 69ad333..dfa7d0b 100644 --- a/src/transaction/keys.rs +++ b/src/transaction/keys.rs @@ -131,15 +131,24 @@ impl TransactionManager { /// `EncryptionNotSupported` — plaintext DB; `InvalidEncryptionKey` — `existing` /// unlocks no slot; `NoFreeKeySlot` — all 8 slots occupied; I/O failures are /// fatal and poison the manager. - pub(crate) fn add_key(&mut self, existing: &crate::crypto::Key, new: &crate::crypto::Key) -> Result<()> { + pub(crate) fn add_key( + &mut self, + existing: &crate::crypto::Key, + new: &crate::crypto::Key, + ) -> Result<()> { if self.poisoned.get() { return Err(ChiselError::Poisoned); } - let header = self.crypto_header.as_ref().ok_or(ChiselError::EncryptionNotSupported)?; + let header = self + .crypto_header + .as_ref() + .ok_or(ChiselError::EncryptionNotSupported)?; let (_idx, dek) = header.unlock(existing)?; // → InvalidEncryptionKey if none let free = header.free_slot().ok_or(ChiselError::NoFreeKeySlot)?; let mut new_header = *header; - new_header.wrap_into(free, new, &dek).map_err(|_| ChiselError::InvalidEncryptionKey)?; + new_header + .wrap_into(free, new, &dek) + .map_err(|_| ChiselError::InvalidEncryptionKey)?; self.rewrite_crypto_header(new_header) } @@ -152,15 +161,24 @@ impl TransactionManager { /// `EncryptionNotSupported` — plaintext DB; `InvalidEncryptionKey` — `old` /// unlocks no slot; `NoFreeKeySlot` — all 8 slots full (no room to stage /// `new` before revoking `old`); I/O failures are fatal and poison the manager. - pub(crate) fn rotate_key(&mut self, old: &crate::crypto::Key, new: &crate::crypto::Key) -> Result<()> { + pub(crate) fn rotate_key( + &mut self, + old: &crate::crypto::Key, + new: &crate::crypto::Key, + ) -> Result<()> { if self.poisoned.get() { return Err(ChiselError::Poisoned); } - let header = self.crypto_header.as_ref().ok_or(ChiselError::EncryptionNotSupported)?; + let header = self + .crypto_header + .as_ref() + .ok_or(ChiselError::EncryptionNotSupported)?; let (old_idx, dek) = header.unlock(old)?; // → InvalidEncryptionKey if none let free = header.free_slot().ok_or(ChiselError::NoFreeKeySlot)?; let mut new_header = *header; - new_header.wrap_into(free, new, &dek).map_err(|_| ChiselError::InvalidEncryptionKey)?; + new_header + .wrap_into(free, new, &dek) + .map_err(|_| ChiselError::InvalidEncryptionKey)?; // Clear the old slot in the same header snapshot — single atomic rewrite. new_header.slots[old_idx] = crate::superblock::KeySlot::EMPTY; self.rewrite_crypto_header(new_header) @@ -184,10 +202,13 @@ impl TransactionManager { if self.poisoned.get() { return Err(ChiselError::Poisoned); } - let header = self.crypto_header.as_ref().ok_or(ChiselError::EncryptionNotSupported)?; + let header = self + .crypto_header + .as_ref() + .ok_or(ChiselError::EncryptionNotSupported)?; let (idx, _dek) = header.unlock(key)?; // → InvalidEncryptionKey if none - // Check AFTER confirming the key is valid: an unknown key on a - // single-slot DB should report InvalidEncryptionKey, not LastKeySlot. + // Check AFTER confirming the key is valid: an unknown key on a + // single-slot DB should report InvalidEncryptionKey, not LastKeySlot. if header.active_count() <= 1 { return Err(ChiselError::LastKeySlot); } @@ -220,8 +241,7 @@ mod tests { crate::DrainInsertion::LruTail, crate::SpillwayLocation::InMemory, ); - let mut tm = - TransactionManager::create_new(cache, 2, Some(raw(0x11)), None).unwrap(); + let mut tm = TransactionManager::create_new(cache, 2, Some(raw(0x11)), None).unwrap(); // Commit once so there is a real baseline superblock to read/write. tm.begin().unwrap(); tm.commit().unwrap(); @@ -300,16 +320,28 @@ mod tests { let counter_before = db.txn_counter; // Unlock slot 0 to get the DEK, then wrap it into a second slot. - let mut new_hdr = db.crypto_header.expect("encrypted DB must have crypto_header"); - let (_, dek) = new_hdr.unlock(&raw(0x11)).expect("slot 0 unlocks with key 0x11"); - new_hdr.wrap_into(1, &raw(0x22), &dek).expect("wrap_into with valid key must succeed"); + let mut new_hdr = db + .crypto_header + .expect("encrypted DB must have crypto_header"); + let (_, dek) = new_hdr + .unlock(&raw(0x11)) + .expect("slot 0 unlocks with key 0x11"); + new_hdr + .wrap_into(1, &raw(0x22), &dek) + .expect("wrap_into with valid key must succeed"); db.rewrite_crypto_header(new_hdr).unwrap(); // txn_counter must have bumped exactly once. - assert_eq!(db.txn_counter, counter_before + 1, "txn_counter must advance"); + assert_eq!( + db.txn_counter, + counter_before + 1, + "txn_counter must advance" + ); // In-memory header must reflect both active slots. - let stored = db.crypto_header.expect("crypto_header must be Some after rewrite"); + let stored = db + .crypto_header + .expect("crypto_header must be Some after rewrite"); assert_eq!(stored.active_count(), 2, "both slots must be active"); assert!(!db.is_poisoned()); } @@ -396,7 +428,9 @@ mod tests { // Add key 0x22 by rewriting the header with a second slot. let mut new_hdr = db.crypto_header.unwrap(); let (_, dek) = new_hdr.unlock(&raw(0x11)).unwrap(); - new_hdr.wrap_into(1, &raw(0x22), &dek).expect("wrap_into with valid key must succeed"); + new_hdr + .wrap_into(1, &raw(0x22), &dek) + .expect("wrap_into with valid key must succeed"); db.rewrite_crypto_header(new_hdr).unwrap(); // Drop to flush OS buffers (fsync already called). drop(db); @@ -467,7 +501,7 @@ mod tests { fn add_key_with_invalid_raw_key_returns_error_not_panic() { let mut db = fresh_encrypted(); let bad_new = Key::Raw(Zeroizing::new(vec![])); // zero-length key material - // This must NOT panic; it must return an Err. + // This must NOT panic; it must return an Err. let result = db.add_key(&raw(0x11), &bad_new); assert!( matches!(result, Err(ChiselError::InvalidEncryptionKey)), diff --git a/src/transaction/recovery.rs b/src/transaction/recovery.rs index 53fea1e..74cf42e 100644 --- a/src/transaction/recovery.rs +++ b/src/transaction/recovery.rs @@ -252,8 +252,8 @@ impl TransactionManager { // is ignored by deserialize). An intact, encrypted page 0 tells us the // stride directly via its cleartext crypto-header — the common case. let page0 = cache.io_mut().read_page(0)?; - if let Some(stride) = Superblock::deserialize(&page0) - .and_then(|sb| sb.encryption.map(|h| h.stride as usize)) + if let Some(stride) = + Superblock::deserialize(&page0).and_then(|sb| sb.encryption.map(|h| h.stride as usize)) { cache.io_mut().set_stride(stride); } @@ -269,9 +269,7 @@ impl TransactionManager { .filter_map(Superblock::deserialize) .find_map(|sb| sb.encryption.map(|h| h.stride as usize)) }; - if encrypted_stride(&candidates).is_none() - && Superblock::deserialize(&page0).is_none() - { + if encrypted_stride(&candidates).is_none() && Superblock::deserialize(&page0).is_none() { // Torn-slot-0 recovery for encrypted DBs: when slot 0 is a torn // write, page 0 fails to deserialize so the anchor above could not // learn the stride, and the default-stride candidate scan finds @@ -578,9 +576,7 @@ fn build_create_cipher( // has effect for Passphrase. let (kdf, params) = match key { crate::crypto::Key::Raw(_) => (KdfId::Hkdf, Argon2Params::default()), - crate::crypto::Key::Passphrase(_) => { - (KdfId::Argon2id, argon2_override.unwrap_or_default()) - } + crate::crypto::Key::Passphrase(_) => (KdfId::Argon2id, argon2_override.unwrap_or_default()), }; let kek = derive_kek(key, kdf, &salt, ¶ms)?; @@ -601,9 +597,16 @@ fn build_create_cipher( let mut slots = [KeySlot::EMPTY; KEY_SLOT_COUNT]; slots[0] = slot; - let header = CryptoHeader { algorithm: ALGO_XCHACHA20POLY1305, stride: 8232, slots }; + let header = CryptoHeader { + algorithm: ALGO_XCHACHA20POLY1305, + stride: 8232, + slots, + }; - Ok(CreateCrypto { page_cipher: crate::crypto::PageCipher::new(dek), header }) + Ok(CreateCrypto { + page_cipher: crate::crypto::PageCipher::new(dek), + header, + }) } /// Try every ACTIVE key-slot in turn: derive the KEK from `key` + the slot's @@ -636,9 +639,13 @@ fn unwrap_first_matching_slot( Err(_) => continue, }; let aad = slot.aad(); - if let Ok(dek) = - unwrap_dek(&kek, &slot.wrapped_dek, &slot.wrap_tag, &slot.wrap_nonce, &aad) - { + if let Ok(dek) = unwrap_dek( + &kek, + &slot.wrapped_dek, + &slot.wrap_tag, + &slot.wrap_nonce, + &aad, + ) { return Ok(dek); } } diff --git a/tests/encryption_keys.rs b/tests/encryption_keys.rs index ba1e69e..a6760f4 100644 --- a/tests/encryption_keys.rs +++ b/tests/encryption_keys.rs @@ -4,8 +4,8 @@ //! The underlying DEK is never re-generated, so add_key / rotate_key are pure //! superblock operations: no page is touched, data survives every credential change. -use chisel::{ChiselError, Chisel, Options}; use chisel::Key; +use chisel::{Chisel, ChiselError, Options}; use tempfile::TempDir; use zeroize::Zeroizing; diff --git a/tests/encryption_open.rs b/tests/encryption_open.rs index eea2b0a..2ea5ac7 100644 --- a/tests/encryption_open.rs +++ b/tests/encryption_open.rs @@ -19,11 +19,7 @@ fn round_trip_open_with_correct_key() { let path = dir.path().join("e.chisel"); let handle; { - let mut db = Chisel::open( - &path, - Options::default().encryption_key(raw_key(0x11)), - ) - .unwrap(); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw_key(0x11))).unwrap(); db.begin().unwrap(); handle = db.allocate(b"hello world").unwrap(); db.commit().unwrap(); @@ -48,11 +44,7 @@ fn wrong_key_is_operational_error_not_panic() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("e.chisel"); { - let mut db = Chisel::open( - &path, - Options::default().encryption_key(raw_key(0x11)), - ) - .unwrap(); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw_key(0x11))).unwrap(); db.begin().unwrap(); db.commit().unwrap(); } @@ -72,7 +64,10 @@ fn wrong_key_is_operational_error_not_panic() { .encryption_key(raw_key(0x11)) .create_if_missing(false), ); - assert!(ok.is_ok(), "correct key must succeed after a wrong-key attempt"); + assert!( + ok.is_ok(), + "correct key must succeed after a wrong-key attempt" + ); } /// Opening an encrypted DB without supplying a key must error. @@ -81,16 +76,15 @@ fn missing_key_on_encrypted_db_errors() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("e.chisel"); { - let mut db = Chisel::open( - &path, - Options::default().encryption_key(raw_key(0x11)), - ) - .unwrap(); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw_key(0x11))).unwrap(); db.begin().unwrap(); db.commit().unwrap(); } let err = Chisel::open(&path, Options::default().create_if_missing(false)); - assert!(err.is_err(), "opening an encrypted DB without a key must fail"); + assert!( + err.is_err(), + "opening an encrypted DB without a key must fail" + ); } /// Supplying a key to a plaintext DB must error. @@ -140,8 +134,7 @@ fn passphrase_key_round_trip() { let pass = || Key::Passphrase(Zeroizing::new("correct horse battery staple".to_string())); let handle; { - let mut db = - Chisel::open(&path, Options::default().encryption_key(pass())).unwrap(); + let mut db = Chisel::open(&path, Options::default().encryption_key(pass())).unwrap(); db.begin().unwrap(); handle = db.allocate(b"secret").unwrap(); db.commit().unwrap(); @@ -169,11 +162,7 @@ fn open_encrypted_db_with_no_commits_uses_correct_key() { // Create with a named root set at create time (so there's something to verify // round-trips even without a user commit). { - let _db = Chisel::open( - &path, - Options::default().encryption_key(raw_key(0x42)), - ) - .unwrap(); + let _db = Chisel::open(&path, Options::default().encryption_key(raw_key(0x42))).unwrap(); // Drop immediately — no begin/commit. This is the exact scenario the // create-seed inversion bug breaks: the winner slot is at page 0 // (counter N-1) but txn_counter % N = N-1 != 0 for N=2. @@ -199,11 +188,7 @@ fn named_root_round_trips_through_encrypted_open() { let path = dir.path().join("named.chisel"); let handle; { - let mut db = Chisel::open( - &path, - Options::default().encryption_key(raw_key(0xAB)), - ) - .unwrap(); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw_key(0xAB))).unwrap(); db.begin().unwrap(); handle = db.allocate(b"payload").unwrap(); db.set_root_name("myroot", handle).unwrap(); @@ -270,11 +255,7 @@ fn multi_page_encrypted_value_round_trips() { let payload: Vec = (0..32 * 1024).map(|i| (i % 251) as u8).collect(); let handle; { - let mut db = Chisel::open( - &path, - Options::default().encryption_key(raw_key(0x77)), - ) - .unwrap(); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw_key(0x77))).unwrap(); db.begin().unwrap(); handle = db.allocate(&payload).unwrap(); db.commit().unwrap(); @@ -313,11 +294,7 @@ fn torn_slot_0_encrypted_db_recovers_via_sibling() { let path = dir.path().join("torn.chisel"); let h1; { - let mut db = Chisel::open( - &path, - Options::default().encryption_key(raw_key(0x99)), - ) - .unwrap(); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw_key(0x99))).unwrap(); // Two commits so BOTH slots (N=2) hold valid post-commit superblocks: // commit 1 → slot 0, commit 2 → slot 1. After corrupting slot 0, // recovery must fall back to slot 1. @@ -331,10 +308,7 @@ fn torn_slot_0_encrypted_db_recovers_via_sibling() { // Simulate a torn write to slot 0: zero its first PAGE_SIZE bytes so the // page-0 image fails to deserialize (anchor cannot learn the stride). { - let mut f = std::fs::OpenOptions::new() - .write(true) - .open(&path) - .unwrap(); + let mut f = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); f.seek(SeekFrom::Start(0)).unwrap(); f.write_all(&[0u8; 8192]).unwrap(); f.sync_all().unwrap(); diff --git a/tests/encryption_roundtrip.rs b/tests/encryption_roundtrip.rs index 31add60..e3e3d1d 100644 --- a/tests/encryption_roundtrip.rs +++ b/tests/encryption_roundtrip.rs @@ -12,7 +12,7 @@ // derivation is exercised in the crypto unit tests. Uses only the public API // (chisel::{Chisel, ChiselError, Key, Options}); no crate-internal paths. -use chisel::{ChiselError, Chisel, Key, Options}; +use chisel::{Chisel, ChiselError, Key, Options}; use zeroize::Zeroizing; fn raw_key(b: u8) -> Key { @@ -26,9 +26,8 @@ fn encrypted_roundtrip_and_wrong_key() { // Create encrypted, write a value, capture the raw handle id, close. let raw_handle = { - let mut db = - Chisel::open(&path, Options::default().encryption_key(raw_key(0xAB))) - .expect("create encrypted"); + let mut db = Chisel::open(&path, Options::default().encryption_key(raw_key(0xAB))) + .expect("create encrypted"); db.begin().expect("begin"); let h = db.allocate(b"secret-payload").expect("allocate"); db.commit().expect("commit"); @@ -83,10 +82,9 @@ fn in_memory_encrypted_roundtrip() { // An in-memory encrypted DB must write and read back within the same session. // There is no reopen for in-memory DBs, so the test covers the allocate → // commit → read path under encryption without touching disk. - let mut db = Chisel::open_in_memory_with_options( - Options::default().encryption_key(raw_key(0x7F)), - ) - .expect("open in-memory encrypted"); + let mut db = + Chisel::open_in_memory_with_options(Options::default().encryption_key(raw_key(0x7F))) + .expect("open in-memory encrypted"); db.begin().expect("begin"); let h1 = db.allocate(b"in-memory-value-alpha").expect("allocate h1"); @@ -102,7 +100,10 @@ fn in_memory_encrypted_roundtrip() { db.commit().expect("commit 2"); assert_eq!(db.read(h1).expect("read h1 after update"), b"updated-alpha"); - assert_eq!(db.read(h2).expect("read h2 unchanged"), b"in-memory-value-beta"); + assert_eq!( + db.read(h2).expect("read h2 unchanged"), + b"in-memory-value-beta" + ); } #[test] diff --git a/tests/public_key_api.rs b/tests/public_key_api.rs index 8370eb6..7e34a91 100644 --- a/tests/public_key_api.rs +++ b/tests/public_key_api.rs @@ -23,7 +23,9 @@ fn passphrase_key_and_argon2_params_public_api() { t_cost: 3, p_cost: 1, }; - let o = Options::default().encryption_key(pass).argon2_params(params); + let o = Options::default() + .encryption_key(pass) + .argon2_params(params); assert!(matches!(o.encryption_key, Some(Key::Passphrase(_)))); let p = o.argon2_params.unwrap(); assert_eq!(p.m_cost, 32768); From e59fdd79c9f178fcb4317a303406b4e6af995cb3 Mon Sep 17 00:00:00 2001 From: Christophe Pettus Date: Wed, 1 Jul 2026 08:48:18 -0700 Subject: [PATCH 42/42] build: hold the 1.82 MSRV floor for the new crypto deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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. --- Cargo.lock | 23 +++++------------------ Cargo.toml | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72a435f..f45e4fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,9 +139,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64ct" -version = "1.8.3" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bit-set" @@ -250,6 +250,7 @@ name = "chisel" version = "0.1.0" dependencies = [ "argon2", + "base64ct", "chacha20poly1305", "getrandom 0.2.17", "hkdf", @@ -1850,23 +1851,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 1560ee2..9c8cdab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,8 +73,22 @@ chacha20poly1305 = "0.10" # XChaCha20-Poly1305 AEAD (192-bit nonce) argon2 = "0.5" # Argon2id passphrase KDF (memory-hard) hkdf = "0.12" # HKDF-SHA256 raw-key KDF sha2 = "0.10" # SHA-256 for HKDF -zeroize = { version = "1", features = ["derive"] } # wipe key material on drop +# I61-style tilde pin: zeroize 1.9.0 AND its `zeroize_derive` macro both +# bumped to `edition2024` (Rust 1.85+), which would push chisel's library +# MSRV above the 1.82 floor enforced by the msrv CI job. `~1.8` (>=1.8.0, +# <1.9.0) holds the last edition-2021 line of the core crate. We use only +# `Zeroizing` — no #[derive(Zeroize)] anywhere — so the `derive` feature +# is dropped entirely, keeping the edition2024 `zeroize_derive` out of the +# tree. Lift when the floor moves past 1.85; mirrors clap `~4.5` in bench. +zeroize = "~1.8" # wipe key material on drop (Zeroizing) getrandom = "0.2" # OS RNG for DEK / nonce / salt generation +# I61-style floor pin on a TRANSITIVE dep (not used directly). argon2 0.5.3 +# hard-depends on base64ct; base64ct 1.7+ went edition2024 (Rust 1.85+), which +# would break the 1.82 MSRV floor (msrv CI job). `~1.6` (>=1.6.0, <1.7.0) holds +# the last edition-2021 line. base64ct only encodes PHC hash strings, which we +# never emit (we call argon2's raw hash_password_into), so the pinned version's +# code path is never exercised. Lift when the MSRV floor moves past 1.85. +base64ct = "~1.6" [dev-dependencies] tempfile = "3"