parity/ is a separate module so the format package keeps zero dependencies. It runs the full loop with two independent engines as oracles: the C library writes -> pure Go reads -> modernc writes into the decrypted database -> pure Go encrypts -> the C library reads back what Go wrote. It skips where libsqlcipher or a C compiler are absent; the committed fixtures cover the C-written direction everywhere. LLM.md records the VFS-vs-codec verdict with its evidence, so it does not get re-litigated from memory: the codec is a pager hook, not a VFS hook, and SQLite checksums WAL frames and journal records over the CIPHERTEXT the codec returns -- verified numerically against a real hot C-written WAL. A VFS shim sits below the pager and cannot reproduce that.
5.0 KiB
hanzoai/sqlcipher — engineering notes
The SQLCipher 4 page format in pure Go. Ported from the C source (v4.5.6), not written from a spec: the docs are incomplete and the source is what defines the format. Zero dependencies — every primitive is stdlib.
Why this is its own module
The format is one concern; a driver is another. Trapping the codec inside
hanzoai/sqlite would brand a reusable value with a place. Anything that must
read or write SQLCipher files — the driver, replicate/backup, a migration, a
forensic tool — depends on this and nothing else.
Verified constants (do not "clean up")
Taken from src/crypto.h + src/crypto_impl.c at v4.5.6 and confirmed against a
live libsqlcipher 4.5.6 via PRAGMA cipher_*:
| Constant | Value | Source |
|---|---|---|
PBKDF2_ITER |
256000 | crypto.h:102 |
FAST_PBKDF2_ITER |
2 | crypto.h:126 |
HMAC_SALT_MASK |
0x3a |
crypto.h:135 |
FILE_HEADER_SZ |
16 | crypto.h:81 |
DEFAULT_CIPHER_FLAGS |
HMAC | LE_PGNO |
crypto.h:118 |
| key / IV / block / HMAC | 32 / 16 / 16 / 64 | crypto_openssl.c |
| reserve | 80 | (iv+hmac) rounded up to block size |
| cipher | EVP_aes_256_cbc(), set_padding(0) |
crypto_openssl.c:91,401 |
Getting one of these wrong means silently unreadable data, so they are pinned by golden vectors and by fixtures the C library wrote.
4.5.6 vs 4.6.1 (what production ships): identical format. 4.6.1 merged
crypto.h/crypto.c/crypto_impl.c into one sqlcipher.c; diffing
sqlcipher_page_cipher, sqlcipher_page_hmac, sqlcipher_cipher_ctx_key_derive
and sqlcipher_codec_ctx_reserve_setup across the two tags shows only log-call
signatures, a static qualifier, and sqlcipher_codec_ctx_get_use_hmac(ctx)
replaced by the equivalent SQLCIPHER_FLAG_GET(ctx->flags, CIPHER_FLAG_HMAC).
Two subtleties that will bite a refactor
- The reserve travels in the database header (SQLite byte 20 = 80). This is
why reading an existing SQLCipher database needs no configuration: SQLite's
lockBtreesetsusableSize = pageSize - page1[20]from the file itself. Confirmed empirically —modernc.org/sqliteinserts correctly into a decrypted reserve=80 database and the C library reads the result. - A decrypted page's reserve holds the IV, which is fresh on every write. So
decrypt → encrypt → decryptis the identity on the usable bytes, not on the trailer. Do not "fix" a round-trip test by comparing whole pages.
VFS vs codec — settled, with evidence
The question was whether a VFS-level layer over modernc.org/sqlite could
implement this format and give us an encrypting pure-Go engine. It cannot.
Three independent blockers, in order of severity:
- The codec is a pager hook, not a VFS hook, and the WAL/journal checksums
cover ciphertext.
wal.c:3852callssqlcipherPagerCodec(pPage)and thenwalEncodeFramechecksums that ciphertext;pager.c:4657/6102do the same for the rollback journal and subjournal viaCODEC2. A VFS shim sits below the pager, so SQLite would checksum the plaintext while the shim wrote ciphertext. Verified numerically against a real hot C-written WAL: recomputingwalChecksumBytesover the on-disk (encrypted) bytes reproduces the stored frame checksums exactly, on every frame. Consequence: a Go process could not recover a C-written hot WAL, and vice versa — recovery would stop at the first frame and silently drop committed transactions. The main database file alone is byte-compatible; the sidecars are not. modernc.org/sqlite/vfsis read-only. It isfs.FS-backed;vfsiofills onlyxClose/xRead/xFileSize/xLock/xUnlock/xCheckReservedLock/xFileControl/ xSectorSize/xDeviceCharacteristics— thexWrite/xTruncate/xSyncslots (offsets +24/+32/+40) are left null.vfsOpenrefusesSQLITE_OPEN_MAIN_JOURNALandxAccessreports not-writable. There is no Go-native read-write VFS API; a shim would mean hand-buildingsqlite3_vfs/sqlite3_io_methodsthrough hardcoded struct offsets andunsafe.- The reserve cannot be set on a new database.
fcntl.goexports onlyFileControlPersistWAL; the genericfileControlis unexported, soSQLITE_FCNTL_RESERVE_BYTESis unreachable. Existing databases are fine (blocker 1 aside) because the reserve is in the header.
The path that does work — should we want a real pure-Go SQLCipher engine — is
to transpile SQLCipher's own amalgamation with ccgo (exactly how
modernc.org/sqlite is built from sqlite3.c) and register a Go crypto provider
via sqlcipher_register_provider(), which is public API (sqlcipher.h:87). That
is byte-compatible by construction — it is SQLCipher's own pager, wal and codec —
and the Go provider is where a post-quantum AEAD would plug in later. It is a
build-toolchain project, not a codec project.
What this module does not do
It does not encrypt a live database. Use it for offline read/write, tooling, migration and verification, and as the format seam for any future engine work.