Skip to content

Local cache encryption work

R22 is in progress. Normal startup now runs through the root bootstrap worker (cache_cipher::bootstrap) with the production policy Existing: a root that already owns a device key takes the encrypted path, and every other root stays plaintext exactly as before. No production root owns a key yet, because key creation and plaintext conversion (Policy::Migrate) are reachable only from tests until the blockers listed at the end of the bootstrap section are resolved. No personal database has been changed by the development fixtures.

The desktop pins SQLCipher 4.19.0 (SQLite 3.53.4) in the vendored libsqlite3-sys build, retaining the newer WAL/FTS fixes. Random device keys are zeroizing, redacted in Debug output and supplied through the C key API before schema access. The bounded credential actor admits and verifies newly stored keys. Missing required keys fail without creating replacements. Device-root key namespaces are separate from imported account/profile credentials.

SQLCipher protects database and journal pages. The build uses TEMP_STORE=2 with a documented native connection policy: ordinary plaintext opens default to FILE, while a keyed main forces memory temporary storage and rejects FILE/DEFAULT changes, including after replacing or removing a SQL authorizer. This avoids introducing unbounded in-memory sorting into existing plaintext startup. Keying an attached scratch database alone leaves a plaintext main's FILE policy intact. Selection membership and frozen reviews now use a private attached encrypted scratch database, with a 2 MiB page-cache target. Full selection ordering builds an index on disk and walks it one row at a time instead of materializing an unbounded sort/window. Conversation duplicate choice and newest-first ordering also use indexed scratch metadata, loading only the requested 20-message page into Rust. Anchor/focus copy precedence is preserved; both timestamps and stable ID ties sort descending.

Scratch uses DELETE rollback journals with synchronous OFF. It is disposable session state, has no crash-durability guarantee, and is never reused after a restart. Normal transaction rollback still protects current session operations; main cache and durable receipt journals retain FULL durability. Qualify the main WAL pragma: an unqualified journal_mode assignment also changes attached scratch. The previous initializer therefore made scratch WAL despite requesting DELETE. The owning cache worker keeps scratch alive until admitted jobs drain and drops the connection before deleting its directory. Reopened sessions start empty. Read-move TEMP projections are bounded at 128 entries. A separate shared-source follow-up replaces ancestry TEMP/recursive sets with an indexed main-database table and a 128-ID frontier; the published source is included in the desktop pin. The remaining SQLite sort/temporary-index audit and portable-transfer scratch still block production activation; guarded crash-orphan cleanup is described below. Known remaining queries include all-account/folder selection summaries, workspace DISTINCT and Inbox GROUP BY counts, and recovered-view automatic indexes. File-based plaintext sorting is preserved; those queries are not claimed safe for a large keyed cache. See SQLCipher's storage design.

The static build disables SQLCipher automatic exit/finalizer cleanup and initializes OpenSSL with the same process-lifetime policy as Rust TLS. A subprocess regression reproduced a crash in sqlite3Codec when a still-owned cache read followed automatic global cleanup; the patched fixture succeeds. Explicit sqlite3_shutdown() still releases state and permits reinitialization once all connections are closed. The vendored lifecycle patch and source hashes record this change. Ordinary application shutdown must still drain admitted saves. The vendored curl initializer applies the same Rust OpenSSL policy inside its existing constructor before libcurl's first crypto call. A combined subprocess regression verifies this ordering; application constructors cannot safely race library constructors to set global crypto policy. CA probing and certificate validation remain unchanged.

The keyed Store carries an immutable key handle for independent connection owners. Backup-upload journals, profile transport/history and discovery use that handle. The shared connection initializer is pinned to e3e69a4ea71baa6bcc6f98f2d567f6b542adc77d; it retains normal workers/file locks and propagates the initializer into nested discovery observations.

Plaintext migration first creates a private encrypted candidate using a read-only source transaction. SQLCipher logical export preserves schema, FTS and BLOBs; application/user versions are copied explicitly. The candidate must pass SQLite integrity and SQLCipher authentication checks before it can be considered for publication. Cancellation removes the candidate and keeps the source. The candidate records a cheap fingerprint of the plaintext (length, mtime, header change counter, WAL length) and publication refuses a candidate whose source changed afterwards.

Publication (cache_cipher::publication) runs under the exclusive ownership guard with the candidate in the cache folder and no open connection to the plaintext. Its steps are, in order: checkpoint the legacy WAL with TRUNCATE and switch the file to DELETE journalling so no WAL/SHM sidecar can outlive a rename (a busy checkpoint or refused mode change means another connection still holds the file and fails the step; an empty leftover log is removed, a non-empty one fails); write <cache>.encryption-journal naming the candidate, its user/application versions and the checkpointed plaintext fingerprint, and stop the candidate from deleting itself; rename the plaintext to <cache>.plaintext-recovery; rename the candidate to the cache name (same folder, directory fsync after each rename); reopen the published file with the key and confirm an authenticated schema read plus the journalled versions; delete the plaintext recovery file; delete the journal. The checkpoint's final flush of the main file opens it read-write on every platform, because Windows only flushes through a writable handle and fails a read-only one with "Access is denied"; a read-only handle would fail every Windows publication before the journal step. Never rename a main database beside old WAL/SHM files.

The plaintext is disposed of by ordinary deletion only after the keyed reopen passed, because the contract requires recovery material until a verified replacement commits and nothing else; keeping a plaintext copy beside an encrypted cache would defeat the feature. Earlier copies and storage-device history are a documented limit, not an overwrite promise.

Startup recovery (publication::recover) derives the state from the journal plus which of main/candidate/recovery exist and the main file header, then resumes or rolls back deterministically: a checkpointed candidate is fully re-verified (integrity, cipher authentication, versions) and continues; a retired plaintext with a verified candidate continues; a published file whose keyed reopen fails while the plaintext recovery file exists is moved aside and the plaintext restored (no write reaches the encrypted file before verification, so nothing is lost); a published file whose plaintext is already disposed of and whose key is wrong is kept with its journal and reported, never reset; a lost candidate restores or keeps the plaintext; any layout this machine does not produce is refused with every file kept. A recovery file with no journal is reported and kept, and blocks a new publication until reviewed. Orphan cleanup then removes .shep-encrypted-*.partial candidates (and their SQLite sidecars) not named by a journal and .shep-cache-scratch-* session folders; import staging files are not touched. Cleanup is only safe because the exclusive guard proves no cooperating owner; the bootstrap section below describes how older, guard-less processes are excluded. Ten tests cover full publication with uncheckpointed WAL rows, stale candidates with retry, a held plaintext connection with retry, foreign guards, interruption before each of the seven steps with resume, wrong-key rollback and refusal, lost candidates, ambiguous layouts and orphan cleanup.

Bootstrap (cache_cipher::bootstrap) is the only production entry to a data root. Bootstrap::start owns one thread, shep-cache-root, behind a bounded channel of one request; the engine's open_workspace sends the data folder, the legacy cache filename and a policy, and awaits the reply. Dropping that future cancels staging through a watch token; the plaintext is kept. On the thread, open_root takes the exclusive guard (or, under Existing only, joins as a reader when a cooperating Shep already owns the root and has therefore run recovery), reads the device-local .cache-root marker naming the key namespace, loads the key through the bounded credential actor (Existing never creates one; Migrate creates the key first and writes the marker with no-clobber persist plus directory fsync second, so a crash between the two leaves a plaintext root that admits a fresh key later), then walks a deterministic inventory of every database in the root: the root folder, profile-sync/, and each profiles/<uuid>/ folder with its own profile-sync/, taking *.sqlite files, the legacy name and journalled mains whose file is absent, never dot-prefixed candidates or import staging. For each main it runs recover before anything opens, authenticates the key with a read-only keyed open of an already keyed file (a wrong key fails here with the file untouched), and stages plus publishes a plaintext file under the same exclusive guard. A plaintext straggler in a keyed root (installed by an older Shep) is therefore converted at the next start under either policy; a reader that meets one reports that the root is being encrypted and retries later. The guard is then re-taken shared (Guard::share, unlock then try-shared so a competing exclusive owner is reported rather than outranked) and handed out as Root { key, guard }. Two Shep processes starting together do not fail each other: joining as a reader and re-taking the shared lock both wait up to two seconds for the other's short exclusive phase, and the marker is re-read after sharing so a key created in between is refused rather than ignored. Catalog::open_in, Store::open_in and backup::journal::Journal::open_beside route every connection through the root's key decision and give the shared guard to their store::worker::Worker, whose owner drops it after the connection: the reader guard is released only when the last owner has drained its admitted writes. The profile transport and history workers (src/profile_sync/) still open through the key handle alone and do not yet hold the guard.

Import staging in a keyed workspace converts logically: a keyed private copy attaches the selected file read-only and unkeyed, re-validates its schema inside the copying transaction (that connection does not share the earlier read snapshot) and the preview fixture marker, exports with sqlcipher_export, copies the user version and application ID that logical export drops, then the copy is reopened keyed for the same full integrity, schema, foreign-key and review checks the plaintext path performs; fences and installation use the same key, so no plaintext staging copy or profile ever exists beside an encrypted cache. The plaintext path keeps the page-copy backup API and its per-page progress. Page progress for the keyed path is reported at start and end only.

Legacy process exclusion: every Shep from this change on holds the reader guard for its whole session, on the plaintext path too, so the population of guard-less processes is limited to older binaries. For those, publication's checkpoint step is the detector: SQLite refuses to leave WAL mode while any other connection, in this or another process, has the file open, because each open WAL connection holds the shared DMS lock on the -shm index for its lifetime, including while idle. A subprocess fixture proves that an idle older Shep holding the legacy cache blocks publication with a "still open" error and that the plaintext stays intact; after it exits, the next start completes the conversion, and a plaintext open of the published file fails without modifying it. The remaining gap is a legacy process that starts between our exclusive acquisition and its first read, or one that opens a root database publication never touches; both are documented limits, not detected states.

Activation is still blocked on: a user-facing or setting-driven way to select Policy::Migrate (there is no staging flag today; migration runs only from tests); the profile transport/history workers retaining the reader guard; bounded selection-summary, catalog and recovered-view sorting, and the index builds inside sqlcipher_export during migration staging and keyed import, which sort under the keyed main's memory temporary storage; native key recovery and platform startup checks, including Windows rename semantics under antivirus or indexer handles, which are only compiled, not executed; and a native scenario exercising a migrated root, since the demo fixture opener is plaintext-only.

The root ownership guard allows current readers and excludes migration/key creation while another cooperating process owns the cache. It explicitly unlocks on drop so an unrelated transient fork cannot extend its flock.

The SQLite backup API supports copies between databases with compatible encryption settings. It rejects plaintext/ciphertext conversion. Keyed raw export uses logical conversion with a separately pinned, URI-readonly source; its atomic candidate lives in the chosen export folder. This remains intentional unencrypted user output, with the existing credentials-excluded contract. Import/migration scratch is implicit application data and must stay keyed. The export-only DBFLAG_VacuumInto patch preserves unindexed rowids as well as indexed mail/FTS; without it SQLCipher renumbers rows in unindexed extension tables. Keyed import keeps bounded cancellation through the progress handler and the exact reviewed-copy publication by rename. SQLCipher backup API support

Data inventory:

Data Current encryption integration
Mail/MIME, draft attachments, outgoing, bulk/move/folder receipts, settings/calendar Keyed Store entry point
Backup upload archive, destination and session Opened beside the cache with its key decision and shared root guard
Profile upload cache, history, discovery and nested observations Keyed constructors/shared initializer; reader guard not yet retained
Local profile catalog, active profiles and imported-marker recovery Routed through the bootstrap Root; production policy keys only roots that already own a key
Raw portable export Keyed readonly source converted to intentional user-selected SQLite output
Import staging Keyed logical conversion, fences and installation when the workspace is keyed
Selection/frozen reviews Attached encrypted scratch, indexed incremental ordering, worker-owned cleanup
Profile ancestry Indexed main-database scratch with bounded frontier, included in shared pin
Remote images and print previews Already memory-only within Shep
Explicit saved attachments, printed PDFs and user-chosen exports/backups User output; not an implicit cache file

The existing plaintext file and recovery material must remain usable until a verified replacement commits. A failure must offer retry/recovery without resetting mail or acknowledging an incomplete migration. Already deleted copies and storage-device history cannot be retroactively encrypted by migrating the active database. Keep these implementation limits out of ordinary navigation.