V7 (was CI-theater): the Dockerfile built/tested with the inert -tags "sqlite_fts5 sqlcipher" recipe -> CodecLinked()=false -> TestEncryptionProof SKIPPED, so the gate ran green while asserting nothing about ciphertext. Switch to -tags "libsqlite3 sqlite_fts5" + the codec CFLAGS/LDFLAGS (-DSQLITE_HAS_CODEC -DSQLITE_USE_URI=1 -lsqlcipher, headers under /usr/include/sqlcipher) and run the test stage with SQLITE_REQUIRE_CODEC=1, which turns the 'codec unlinked' skip into a HARD FAILURE. The image's own gate now asserts real ciphertext; a regression to the inert recipe fails the build. V4 (defense-in-depth): WrapDEK/UnwrapDEK now take a principal-binding GCM aad; add PrincipalAAD(type,id) = the SAME length-prefixed injective encoding used for the KEK's HKDF info (DRY). The on-disk AAD is version-byte||aad, so a wrapped DEK is bound to its principal AND downgrade-proof; a sidecar moved to another principal fails the tag even if a KEK derivation ever collided. TestEnvelopeRotation extended with cross-principal negative checks. PATCH bump v0.1.3 -> v0.1.4. Verified: CGO=0 green; CGO=1+codec green with proof RUN (not skipped); inert-tag+REQUIRE fails as intended.
Hanzo SQLite
Dual-backend SQLite driver for the Hanzo ecosystem. Registers the
database/sql driver name sqlite under both build configurations and
exposes the same API either way:
| Build | Backend | Encryption | Use |
|---|---|---|---|
CGO_ENABLED=1 + -tags libsqlite3 + libsqlcipher |
mattn/go-sqlite3 → SQLCipher | AES-256 page-level, at rest | production |
CGO_ENABLED=0 |
modernc.org/sqlite (pure Go) | none | CI tests / lint / local dev |
One import, one driver name, two backends:
import _ "github.com/hanzoai/sqlite" // registers "sqlite" under both tags
db, _ := sql.Open("sqlite", dsn) // mattn+SQLCipher (cgo) or modernc (!cgo)
The pure-Go backend cannot encrypt. Demanding a key on it
(Open(path, WithKey(...)), OpenDB(path, key)) returns
ErrEncryptionUnavailable and writes nothing — it never silently persists
plaintext.
Building the encrypted (production) backend — READ THIS
mainline mattn/go-sqlite3 has no sqlcipher build tag and no
sqlite3_key() binding. SQLCipher works only when you:
- link the system sqlite (the
libsqlite3tag) against libsqlcipher, and - enable the codec + URI keying via CGO flags, and
- supply the key as SQLCipher's native URI
keyparameter so it is applied insidesqlite3_open_v2— before mattn's pragma battery runs.
CGO_ENABLED=1 \
CGO_CFLAGS="-DSQLITE_HAS_CODEC -DSQLITE_USE_URI=1 -I<sqlcipher>/include/sqlcipher" \
CGO_LDFLAGS="-L<sqlcipher>/lib -lsqlcipher" \
go build -tags "libsqlite3 sqlite_fts5" ./...
Alpine: apk add gcc musl-dev sqlcipher-dev pkgconfig.
Why not -tags sqlcipher + PRAGMA key in a ConnectHook?
Both are traps that ship plaintext:
-tags sqlcipheris inert in mainline mattn (no such tag) → links plain sqlite →PRAGMA keyis a silent no-op.- mattn runs
PRAGMA busy_timeout/journal_mode/foreign_keys/...viasqlite3_execbefore the ConnectHook fires. On an existing encrypted file that touches the header before the key is set →file is not a databaseon reopen. So a ConnectHook can create but never reopen a SQLCipher DB.
The URI key parameter sidesteps both: SQLCipher's VFS keys the connection at
open time. TestEncryptionProof asserts real ciphertext on disk and a working
keyed reopen, so a mis-linked build fails CI instead of shipping plaintext.
The key rides the DSN (
file:PATH?...&key=x'HEX'). Never log the DSN. IAM keepsshowSql=falseand does not log it.
Encryption
- Algorithm: AES-256 (SQLCipher 4 defaults: 4096-byte pages, PBKDF2-HMAC-SHA512, 256000 iters, per-page HMAC-SHA512).
- Key: raw 256-bit (no passphrase KDF) via
WithRawKey/ thekey=x'HEX'DSN param, or derived per principal:
// CEK = HKDF-SHA256(masterKey, "{org|user}:{id}")
db, _ := sqlite.Open("data.db", sqlite.WithPrincipalKey(masterKey, sqlite.PrincipalOrg, "acme"))
// Or get a *sql.DB to hand to xorm.NewEngineWithDB:
cek, _ := sqlite.DeriveKey(masterKey, sqlite.PrincipalOrg, "acme")
sqldb, _ := sqlite.OpenDB(dbPath, cek)
eng, _ := xorm.NewEngineWithDB("sqlite", "", core.FromDB(sqldb))
Different orgs/users get different CEKs (domain-separated info); destroying the
master key renders every derived CEK irrecoverable.
Per-principal CEK
DeriveKey(masterKey, principalType, principalID) → 32-byte CEK via HKDF-SHA256.
Master key is 32 bytes, sourced from KMS. Used for per-org and per-user database
isolation in IAM, KMS, and other Hanzo services.
Threshold write attestation
ThresholdManager coordinates t-of-n Ed25519 attestations for writes (MPC shard
storage, multi-sig). Pure Go; available under both backends.
License
Apache-2.0